diff --git a/build.gradle.kts b/build.gradle.kts index a1a0bc7a..9af6c02d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,5 +8,6 @@ plugins { alias(libs.plugins.jetbrainsCompose) apply false alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinSerialization) apply false alias(libs.plugins.vanniktechPublish) apply false -} +} \ No newline at end of file diff --git a/demo/composeApp/src/androidMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/SwissMapVectorDemo.android.kt b/demo/composeApp/src/androidMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/SwissMapVectorDemo.android.kt new file mode 100644 index 00000000..9c0ae03d --- /dev/null +++ b/demo/composeApp/src/androidMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/SwissMapVectorDemo.android.kt @@ -0,0 +1,14 @@ +package ovh.plrapps.mapcompose.demo.ui.screens + +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel +import ovh.plrapps.mapcompose.demo.viewmodels.SwissMapVectorDemoVM + +actual object SwissMapVectorDemo { + @Composable + actual fun Content() { + val viewModel: SwissMapVectorDemoVM = viewModel() + + SwissMapVectorCommonUi(viewModel) + } +} \ No newline at end of file diff --git a/demo/composeApp/src/androidMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.android.kt b/demo/composeApp/src/androidMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.android.kt new file mode 100644 index 00000000..70cdced5 --- /dev/null +++ b/demo/composeApp/src/androidMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.android.kt @@ -0,0 +1,14 @@ +package ovh.plrapps.mapcompose.demo.ui.screens + +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel +import ovh.plrapps.mapcompose.demo.viewmodels.VectorDemoVM + +actual object VectorDemo { + @Composable + actual fun Content() { + val screenModel: VectorDemoVM = viewModel() + + VectorCommonUi(screenModel) + } +} \ No newline at end of file diff --git a/demo/composeApp/src/androidMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/Http.kt b/demo/composeApp/src/androidMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/Http.kt index aec69332..552856ca 100644 --- a/demo/composeApp/src/androidMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/Http.kt +++ b/demo/composeApp/src/androidMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/Http.kt @@ -1,7 +1,12 @@ package ovh.plrapps.mapcompose.demo.viewmodels +import kotlinx.io.Buffer +import kotlinx.io.RawSource import kotlinx.io.asSource +import org.jetbrains.compose.resources.ExperimentalResourceApi import ovh.plrapps.mapcompose.core.TileStreamProvider +import ovh.plrapps.mapcompose.vector.core.VectorTileStreamProvider +import ovh.plrapps.mapcomposemp.demo.Res import java.net.HttpURLConnection import java.net.URL @@ -42,4 +47,44 @@ actual fun makeOsmTileStreamProvider() : TileStreamProvider { null } } +} + +actual class OSMVectorTileStreamProvider actual constructor(actual override val styleUrl: String) : + VectorTileStreamProvider { + + @OptIn(ExperimentalResourceApi::class) + actual override suspend fun loadResources(url: String): RawSource? { + return when (url) { + "files/style_street_v2.json" -> { + val buffer = Buffer() + buffer.write(Res.readBytes(url)) + buffer + } + else -> getResourceAsStream(url) + } + } + + actual override suspend fun getTileStream( + tileUrl: String, + row: Int, + col: Int, + zoomLvl: Int + ): RawSource? { + return getResourceAsStream(tileUrl) + } + + private fun getResourceAsStream(url: String): RawSource? { + return try { + val url = URL(url) + val connection = url.openConnection() as HttpURLConnection + // OSM requires a user-agent + connection.setRequestProperty("User-Agent", "Chrome/120.0.0.0 Safari/537.36") + connection.doInput = true + connection.connect() + connection.inputStream.asSource() + } catch (e: Exception) { + e.printStackTrace() + null + } + } } \ No newline at end of file diff --git a/demo/composeApp/src/commonMain/composeResources/files/style_street_v2.json b/demo/composeApp/src/commonMain/composeResources/files/style_street_v2.json new file mode 100644 index 00000000..51e3556f --- /dev/null +++ b/demo/composeApp/src/commonMain/composeResources/files/style_street_v2.json @@ -0,0 +1,8247 @@ +{ + "version": 8, + "id": "streets-v2", + "name": "Streets", + "sources": { + "maptiler_attribution": { + "attribution": "© MapTiler © OpenStreetMap contributors", + "type": "vector" + }, + "maptiler_planet": { + "url": "https://api.maptiler.com/tiles/v3/tiles.json?key=Tp9K7a8qzMTkBSzO4EZb", + "type": "vector" + } + }, + "layers": [ + { + "id": "Background", + "type": "background", + "layout": { + "visibility": "visible" + }, + "paint": { + "background-color": { + "stops": [ + [ + 6, + "hsl(47,79%,94%)" + ], + [ + 14, + "hsl(42,49%,93%)" + ] + ] + } + } + }, + { + "id": "Meadow", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "globallandcover", + "maxzoom": 8, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(75,51%,85%)", + "fill-opacity": { + "stops": [ + [ + 0, + 1 + ], + [ + 8, + 0.1 + ] + ] + } + }, + "filter": [ + "==", + "class", + "grass" + ] + }, + { + "id": "Scrub", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "globallandcover", + "maxzoom": 8, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(97,51%,80%)", + "fill-opacity": { + "stops": [ + [ + 0, + 1 + ], + [ + 8, + 0.1 + ] + ] + } + }, + "filter": [ + "==", + "class", + "scrub" + ] + }, + { + "id": "Crop", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "globallandcover", + "maxzoom": 8, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(50,67%,86%)", + "fill-opacity": { + "stops": [ + [ + 0, + 1 + ], + [ + 8, + 0.1 + ] + ] + } + }, + "filter": [ + "==", + "class", + "crop" + ] + }, + { + "id": "Glacier", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landcover", + "maxzoom": 24, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(0,0%,100%)", + "fill-opacity": { + "stops": [ + [ + 0, + 1 + ], + [ + 10, + 0.7 + ] + ] + } + }, + "filter": [ + "==", + "class", + "ice" + ] + }, + { + "id": "Forest", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "globallandcover", + "maxzoom": 8, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(119,38%,76%)", + "fill-opacity": { + "stops": [ + [ + 1, + 0.8 + ], + [ + 8, + 0 + ] + ] + } + }, + "filter": [ + "in", + "class", + "forest", + "tree" + ] + }, + { + "id": "Sand", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landcover", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": false, + "fill-color": "hsl(52,93%,89%)", + "fill-opacity": 0.85 + }, + "metadata": {}, + "filter": [ + "==", + "class", + "sand" + ] + }, + { + "id": "Wood", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landcover", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(87,46%,85%)", + "fill-opacity": 1 + }, + "metadata": {}, + "filter": [ + "==", + "class", + "wood" + ] + }, + { + "id": "Residential", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landuse", + "maxzoom": 24, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [ + 4, + "hsl(44,34%,87%)" + ], + [ + 16, + "hsl(54, 45%, 91%)" + ] + ] + } + }, + "metadata": {}, + "filter": [ + "in", + "class", + "residential", + "suburbs", + "neighbourhood" + ] + }, + { + "id": "Industrial", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landuse", + "maxzoom": 24, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 9, + [ + "match", + [ + "get", + "class" + ], + [ + "industrial" + ], + "hsl(40,67%,90%)", + "quarry", + "hsla(32, 47%, 87%, 0.2)", + "hsl(60, 31%, 87%)" + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "industrial" + ], + "hsl(49,54%,90%)", + "quarry", + "hsla(32, 47%, 87%, 0.5)", + "hsl(60, 31%, 87%)" + ] + ], + "fill-opacity": [ + "step", + [ + "zoom" + ], + 1, + 9, + [ + "match", + [ + "get", + "class" + ], + "quarry", + 0, + 1 + ], + 10, + 1 + ] + }, + "metadata": {}, + "filter": [ + "in", + "class", + "industrial", + "quarry" + ] + }, + { + "id": "Grass", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landcover", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": false, + "fill-color": "hsl(103, 40%, 85%)", + "fill-opacity": 0.5 + }, + "metadata": {}, + "filter": [ + "==", + "class", + "grass" + ] + }, + { + "id": "Airport zone", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "aeroway", + "minzoom": 11, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(0,0%,93%)", + "fill-opacity": 1 + }, + "metadata": {}, + "filter": [ + "==", + "$type", + "Polygon" + ] + }, + { + "id": "Pedestrian", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(43,100%,99%)", + "fill-opacity": 0.7 + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "!has", + "brunnel" + ], + [ + "!in", + "class", + "bridge", + "pier" + ], + [ + "in", + "subclass", + "pedestrian", + "platform" + ] + ] + }, + { + "id": "Cemetery", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landuse", + "minzoom": 9, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(0,0%,88%)", + "fill-opacity": { + "stops": [ + [ + 9, + 0.25 + ], + [ + 16, + 1 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "==", + "class", + "cemetery" + ] + }, + { + "id": "Hospital", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landuse", + "minzoom": 9, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(12,63%,94%)", + "fill-opacity": { + "stops": [ + [ + 9, + 0.25 + ], + [ + 16, + 1 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "==", + "class", + "hospital" + ] + }, + { + "id": "Stadium", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landuse", + "minzoom": 9, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(94, 100%, 88%)", + "fill-opacity": { + "stops": [ + [ + 9, + 0.25 + ], + [ + 16, + 1 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "in", + "class", + "pitch", + "stadium", + "playground" + ] + }, + { + "id": "School", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landuse", + "minzoom": 9, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(194,52%,94%)", + "fill-opacity": { + "stops": [ + [ + 9, + 0.25 + ], + [ + 16, + 1 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "in", + "class", + "college", + "school", + "university" + ] + }, + { + "id": "River tunnel", + "type": "line", + "source": "maptiler_planet", + "source-layer": "waterway", + "minzoom": 14, + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(210,73%,78%)", + "line-dasharray": [ + 2, + 4 + ], + "line-opacity": 0.5, + "line-width": { + "base": 1.3, + "stops": [ + [ + 12, + 0.5 + ], + [ + 20, + 6 + ] + ] + } + }, + "filter": [ + "==", + "brunnel", + "tunnel" + ] + }, + { + "id": "River", + "type": "line", + "source": "maptiler_planet", + "source-layer": "waterway", + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(210,73%,78%)", + "line-width": { + "stops": [ + [ + 12, + 0.5 + ], + [ + 20, + 6 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "!=", + "brunnel", + "tunnel" + ] + }, + { + "id": "Water intermittent", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "water", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(205,91%,83%)", + "fill-opacity": 0.85 + }, + "metadata": {}, + "filter": [ + "==", + "intermittent", + 1 + ] + }, + { + "id": "Water", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "water", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(204,92%,75%)", + "fill-opacity": [ + "match", + [ + "get", + "intermittent" + ], + 1, + 0.85, + 1 + ] + }, + "metadata": {}, + "filter": [ + "!=", + "intermittent", + 1 + ] + }, + { + "id": "Aeroway", + "type": "line", + "source": "maptiler_planet", + "source-layer": "aeroway", + "minzoom": 11, + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,100%)", + "line-width": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 11, + [ + "match", + [ + "get", + "class" + ], + [ + "runway" + ], + 3, + 0.5 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "runway" + ], + 16, + 6 + ] + ] + }, + "metadata": {} + }, + { + "id": "Heliport", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "aeroway", + "minzoom": 11, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(0,0%,100%)", + "fill-opacity": 1 + }, + "metadata": {}, + "filter": [ + "in", + "class", + "helipad", + "heliport" + ] + }, + { + "id": "Ferry line", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 6, + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": { + "stops": [ + [ + 10, + "hsl(205,61%,63%)" + ], + [ + 16, + "hsl(205,67%,47%)" + ] + ] + }, + "line-dasharray": [ + 2, + 2 + ], + "line-opacity": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 6, + 0.5, + 7, + 0.8, + 8, + 1 + ], + "line-width": { + "stops": [ + [ + 10, + 0.5 + ], + [ + 14, + 1.1 + ] + ] + } + }, + "filter": [ + "==", + "class", + "ferry" + ] + }, + { + "id": "Tunnel outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": [ + "match", + [ + "get", + "class" + ], + "motorway", + "hsl(28,72%,69%)", + [ + "trunk", + "primary" + ], + "hsl(28,72%,69%)", + "hsl(36,5%,80%)" + ], + "line-dasharray": [ + 0.5, + 0.25 + ], + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 6, + 0, + 7, + 0.5, + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 0, + 2.5 + ], + [ + "trunk", + "primary" + ], + 2, + 0 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 2, + 6 + ], + [ + "trunk", + "primary" + ], + 3, + [ + "secondary", + "tertiary" + ], + 2, + [ + "minor", + "service", + "track" + ], + 1, + 0.5 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 5, + 8 + ], + [ + "trunk" + ], + 4, + [ + "primary" + ], + 6, + [ + "secondary" + ], + 6, + [ + "tertiary" + ], + 4, + [ + "minor", + "service", + "track" + ], + 3, + 3 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway", + "trunk", + "primary" + ], + 10, + [ + "secondary" + ], + 8, + [ + "tertiary" + ], + 8, + [ + "minor", + "service", + "track" + ], + 4, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway", + "trunk", + "primary" + ], + 26, + [ + "secondary" + ], + 26, + [ + "tertiary" + ], + 26, + [ + "minor", + "service", + "track" + ], + 18, + 18 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "!in", + "class", + "bridge", + "ferry", + "rail", + "transit", + "pier", + "path", + "aerialway", + "motorway_construction", + "trunk_construction", + "primary_construction", + "secondary_construction", + "tertiary_construction", + "minor_construction", + "service_construction", + "track_construction" + ] + ] + }, + { + "id": "Tunnel", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": [ + "match", + [ + "get", + "class" + ], + "motorway", + "hsl(35,100%,76%)", + [ + "trunk", + "primary" + ], + "hsl(48,100%,88%)", + "hsl(0,0%,96%)" + ], + "line-opacity": 1, + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 5, + 0, + 6, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "brunnel" + ], + [ + "bridge" + ], + 0, + 1 + ], + [ + "trunk", + "primary" + ], + 0, + 0 + ], + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 0, + 2.5 + ], + [ + "trunk", + "primary" + ], + 1.5, + 1 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 1, + 4 + ], + [ + "trunk" + ], + 2.5, + [ + "primary" + ], + 2.5, + [ + "secondary", + "tertiary" + ], + 1.5, + [ + "minor", + "service", + "track" + ], + 1, + 1 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 5, + 6 + ], + [ + "trunk" + ], + 3, + [ + "primary" + ], + 5, + [ + "secondary" + ], + 4, + [ + "tertiary" + ], + 3, + [ + "minor", + "service", + "track" + ], + 2, + 2 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway", + "trunk", + "primary" + ], + 8, + [ + "secondary" + ], + 7, + [ + "tertiary" + ], + 6, + [ + "minor", + "service", + "track" + ], + 4, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway", + "trunk", + "primary" + ], + 24, + [ + "secondary" + ], + 24, + [ + "tertiary" + ], + 24, + [ + "minor", + "service", + "track" + ], + 16, + 16 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "!in", + "class", + "ferry", + "rail", + "transit", + "pier", + "bridge", + "path", + "aerialway", + "motorway_construction", + "trunk_construction", + "primary_construction", + "secondary_construction", + "tertiary_construction", + "minor_construction", + "service_construction", + "track_construction" + ] + ] + }, + { + "id": "Railway tunnel", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,73%)", + "line-opacity": 0.5, + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ] + }, + { + "id": "Railway tunnel hatching", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,73%)", + "line-dasharray": [ + 0.2, + 8 + ], + "line-opacity": 0.5, + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 3 + ], + [ + 20, + 8 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ] + }, + { + "id": "Footway tunnel outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 12, + "layout": { + "line-cap": "round", + "line-join": "miter", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,100%)", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 14, + 0 + ], + [ + 16, + 0 + ], + [ + 18, + 4 + ], + [ + 22, + 8 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "path", + "pedestrian" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ] + }, + { + "id": "Footway tunnel", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 12, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,63%)", + "line-dasharray": { + "stops": [ + [ + 14, + [ + 1, + 0.5 + ] + ], + [ + 18, + [ + 1, + 0.25 + ] + ] + ] + }, + "line-opacity": 0.4, + "line-width": { + "base": 1.2, + "stops": [ + [ + 14, + 0.5 + ], + [ + 16, + 1 + ], + [ + 18, + 2 + ], + [ + 22, + 5 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "path", + "pedestrian" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ] + }, + { + "id": "Pier", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(42,49%,93%)" + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "class", + "pier" + ] + ] + }, + { + "id": "Pier road", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(42,49%,93%)", + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1 + ], + [ + 17, + 4 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "pier" + ] + ] + }, + { + "id": "Bridge outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 17, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(43, 50%, 93%)", + "line-opacity": 0.5, + "line-width": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 13, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 5, + 8 + ], + 15, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 15, + 22 + ], + 17, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 20, + 24 + ] + ] + }, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "motorway", + "primary", + "trunk" + ] + ] + }, + { + "id": "Bridge", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(42,49%,93%)", + "fill-opacity": 0.6 + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "brunnel", + "bridge" + ] + ] + }, + { + "id": "Minor road outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(36,5%,80%)", + "line-opacity": 1, + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 6, + 0, + 7, + 0.5, + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary", + "tertiary" + ], + 2, + [ + "minor", + "service", + "track" + ], + 1, + 0.5 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary" + ], + 6, + [ + "tertiary" + ], + 4, + [ + "minor", + "service", + "track" + ], + 3, + 3 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary" + ], + 8, + [ + "tertiary" + ], + 8, + [ + "minor", + "service", + "track" + ], + 4, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary" + ], + 26, + [ + "tertiary" + ], + 26, + [ + "minor", + "service", + "track" + ], + 18, + 18 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "!in", + "class", + "aerialway", + "bridge", + "ferry", + "minor_construction", + "motorway", + "motorway_construction", + "path", + "path_construction", + "pier", + "primary", + "primary_construction", + "rail", + "secondary_construction", + "service_construction", + "tertiary_construction", + "track_construction", + "transit", + "trunk_construction" + ] + ] + }, + { + "id": "Major road outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(28,72%,69%)", + "line-opacity": 1, + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 6, + 0, + 7, + 0.5, + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 2.4, + 0 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 3, + 0.5 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk" + ], + 4, + [ + "primary" + ], + 6, + 3 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 10, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 26, + 18 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "primary", + "trunk" + ] + ] + }, + { + "id": "Highway outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(28,72%,69%)", + "line-opacity": 1, + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 6, + 0, + 7, + 0.5, + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 0, + 2.5 + ], + 0 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 2, + 6 + ], + 0.5 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 5, + 8 + ], + 3 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + 10, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + 26, + 18 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + }, + { + "id": "Road under construction", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "square", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": [ + "match", + [ + "get", + "class" + ], + "motorway_construction", + "hsl(35,100%,76%)", + [ + "trunk_construction", + "primary_construction" + ], + "hsl(48,100%,83%)", + "hsl(0,0%,100%)" + ], + "line-dasharray": [ + 2, + 2 + ], + "line-opacity": [ + "case", + [ + "==", + [ + "get", + "brunnel" + ], + "tunnel" + ], + 0.7, + 1 + ], + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 5, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway_construction" + ], + [ + "match", + [ + "get", + "brunnel" + ], + [ + "bridge" + ], + 0, + 0.5 + ], + [ + "trunk_construction", + "primary_construction" + ], + 0, + 0 + ], + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway_construction" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 0, + 2.5 + ], + [ + "trunk_construction", + "primary_construction" + ], + 1.5, + 1 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway_construction" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 1, + 4 + ], + [ + "trunk_construction" + ], + 2.5, + [ + "primary_construction" + ], + 2.5, + [ + "secondary_construction", + "tertiary_construction" + ], + 1.5, + [ + "minor_construction", + "service_construction", + "track_construction" + ], + 1, + 1 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway_construction" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 5, + 6 + ], + [ + "trunk_construction" + ], + 3, + [ + "primary_construction" + ], + 5, + [ + "secondary_construction" + ], + 4, + [ + "tertiary_construction" + ], + 3, + [ + "minor_construction", + "service_construction", + "track_construction" + ], + 2, + 2 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway_construction", + "trunk_construction", + "primary_construction" + ], + 8, + [ + "secondary_construction" + ], + 7, + [ + "tertiary_construction" + ], + 6, + [ + "minor_construction", + "service_construction", + "track_construction" + ], + 4, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway_construction", + "trunk_construction", + "primary_construction" + ], + 24, + [ + "secondary_construction" + ], + 24, + [ + "tertiary_construction" + ], + 24, + [ + "minor_construction", + "service_construction", + "track_construction" + ], + 16, + 16 + ] + ] + }, + "metadata": {}, + "filter": [ + "in", + "class", + "motorway_construction", + "trunk_construction", + "primary_construction", + "secondary_construction", + "tertiary_construction", + "minor_construction", + "service_construction", + "track_construction" + ] + }, + { + "id": "Minor road", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,100%)", + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 5, + 0.5, + 10, + 1, + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary", + "tertiary" + ], + 1.5, + [ + "minor", + "service", + "track" + ], + 1, + 1 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary" + ], + 4, + [ + "tertiary" + ], + 3, + [ + "minor", + "service", + "track" + ], + 2, + 2 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary" + ], + 7, + [ + "tertiary" + ], + 6, + [ + "minor", + "service", + "track" + ], + 4, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary" + ], + 24, + [ + "tertiary" + ], + 24, + [ + "minor", + "service", + "track" + ], + 16, + 16 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "!in", + "class", + "aerialway", + "bridge", + "ferry", + "minor_construction", + "motorway", + "motorway_construction", + "path", + "path_construction", + "pier", + "primary", + "primary_construction", + "rail", + "secondary_construction", + "service_construction", + "tertiary_construction", + "track_construction", + "transit", + "trunk_construction" + ] + ] + }, + { + "id": "Major road", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(48,100%,83%)", + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 1.5, + 1 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 2.5, + 1 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk" + ], + 3, + [ + "primary" + ], + 5, + 2 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 8, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 24, + 16 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "primary", + "trunk" + ] + ] + }, + { + "id": "Highway", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(35,100%,76%)", + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 5, + 0.5, + 6, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "brunnel" + ], + [ + "bridge" + ], + 0, + 1 + ], + 0 + ], + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 0, + 2.5 + ], + 1 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 1, + 4 + ], + 1 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 5, + 6 + ], + 2 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + 8, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + 24, + 16 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + }, + { + "id": "Path outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 12, + "layout": { + "line-cap": "round", + "line-join": "miter", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,100%)", + "line-width": { + "base": 1.2, + "stops": [ + [ + 14, + 0 + ], + [ + 16, + 0 + ], + [ + 18, + 4 + ], + [ + 22, + 8 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "path", + "pedestrian" + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ] + }, + { + "id": "Path minor", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 12, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 79%)", + "line-dasharray": { + "stops": [ + [ + 14, + [ + 1, + 0.5 + ] + ], + [ + 18, + [ + 1, + 0.25 + ] + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 14, + 0.5 + ], + [ + 16, + 1 + ], + [ + 18, + 2 + ], + [ + 22, + 5 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "path_pedestrian" + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ] + }, + { + "id": "Path", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 12, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 79%)", + "line-dasharray": { + "stops": [ + [ + 14, + [ + 1, + 0.5 + ] + ], + [ + 18, + [ + 1, + 0.25 + ] + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 14, + 0.5 + ], + [ + 16, + 1 + ], + [ + 18, + 2 + ], + [ + 22, + 5 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "path", + "pedestrian" + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ] + }, + { + "id": "Major rail", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": { + "stops": [ + [ + 8, + "hsl(0,0%,72%)" + ], + [ + 16, + "hsl(0,0%,70%)" + ] + ] + }, + "line-opacity": [ + "match", + [ + "get", + "service" + ], + "yard", + 0.5, + 1 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "!in", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ] + }, + { + "id": "Major rail hatching", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,72%)", + "line-dasharray": [ + 0.2, + 9 + ], + "line-opacity": [ + "match", + [ + "get", + "service" + ], + "yard", + 0.5, + 1 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 3 + ], + [ + 20, + 8 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "!in", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ] + }, + { + "id": "Minor rail", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,73%)", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "in", + "subclass", + "light_rail", + "tram" + ] + }, + { + "id": "Minor rail hatching", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,73%)", + "line-dasharray": [ + 0.2, + 4 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 2 + ], + [ + 20, + 6 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "in", + "subclass", + "tram", + "light_rail" + ] + }, + { + "id": "Building", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "building", + "minzoom": 13, + "maxzoom": 15, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(30,6%,73%)", + "fill-opacity": 0.3, + "fill-outline-color": { + "base": 1, + "stops": [ + [ + 13, + "hsla(35, 6%, 79%, 0.3)" + ], + [ + 14, + "hsl(35, 6%, 79%)" + ] + ] + } + }, + "metadata": {} + }, + { + "id": "Building 3D", + "type": "fill-extrusion", + "source": "maptiler_planet", + "source-layer": "building", + "minzoom": 15, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-extrusion-base": { + "property": "render_min_height", + "type": "identity" + }, + "fill-extrusion-color": "hsl(44,14%,79%)", + "fill-extrusion-height": { + "property": "render_height", + "type": "identity" + }, + "fill-extrusion-opacity": 0.4 + }, + "metadata": {}, + "filter": [ + "!has", + "hide_3d" + ] + }, + { + "id": "Aqueduct outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "waterway", + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,51%)", + "line-width": { + "base": 1.3, + "stops": [ + [ + 14, + 1 + ], + [ + 20, + 6 + ] + ] + } + }, + "filter": [ + "==", + "brunnel", + "bridge" + ] + }, + { + "id": "Aqueduct", + "type": "line", + "source": "maptiler_planet", + "source-layer": "waterway", + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(204,92%,75%)", + "line-width": { + "base": 1.3, + "stops": [ + [ + 12, + 0.5 + ], + [ + 20, + 5 + ] + ] + } + }, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "bridge" + ] + ] + }, + { + "id": "Cablecar", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 13, + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-blur": 1, + "line-color": "hsl(0,0%,100%)", + "line-width": { + "base": 1, + "stops": [ + [ + 13, + 2 + ], + [ + 19, + 4 + ] + ] + } + }, + "filter": [ + "==", + "class", + "aerialway" + ] + }, + { + "id": "Cablecar dash", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "bevel", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,64%)", + "line-dasharray": [ + 2, + 2 + ], + "line-width": { + "base": 1, + "stops": [ + [ + 13, + 1 + ], + [ + 19, + 2 + ] + ] + } + }, + "filter": [ + "==", + "class", + "aerialway" + ] + }, + { + "id": "Other border", + "type": "line", + "source": "maptiler_planet", + "source-layer": "boundary", + "minzoom": 3, + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 70%)", + "line-dasharray": [ + 2, + 1 + ], + "line-width": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 3, + 0.75, + 4, + 0.8, + 11, + [ + "case", + [ + "<=", + [ + "get", + "admin_level" + ], + 6 + ], + 1.75, + 1.5 + ], + 18, + [ + "case", + [ + "<=", + [ + "get", + "admin_level" + ], + 6 + ], + 3, + 2 + ] + ] + }, + "filter": [ + "all", + [ + "in", + "admin_level", + 3, + 4, + 5, + 6, + 7, + 8 + ], + [ + "==", + "maritime", + 0 + ] + ] + }, + { + "id": "Disputed border", + "type": "line", + "source": "maptiler_planet", + "source-layer": "boundary", + "minzoom": 0, + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 63%)", + "line-dasharray": [ + 2, + 2 + ], + "line-width": { + "stops": [ + [ + 1, + 0.5 + ], + [ + 5, + 1.5 + ], + [ + 10, + 2 + ], + [ + 24, + 12 + ] + ] + } + }, + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "==", + "disputed", + 1 + ], + [ + "==", + "maritime", + 0 + ] + ] + }, + { + "id": "Country border", + "type": "line", + "source": "maptiler_planet", + "source-layer": "boundary", + "minzoom": 0, + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 54%)", + "line-width": { + "stops": [ + [ + 1, + 0.5 + ], + [ + 5, + 1.5 + ], + [ + 10, + 2 + ], + [ + 24, + 12 + ] + ] + } + }, + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "==", + "disputed", + 0 + ], + [ + "==", + "maritime", + 0 + ] + ] + }, + { + "id": "River labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "waterway", + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 400, + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Italic", + "Noto Sans Italic" + ], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 16, + 14 + ], + [ + 22, + 20 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(205,84%,39%)", + "text-halo-blur": 1, + "text-halo-color": "hsl(202, 76%, 82%)", + "text-halo-width": { + "stops": [ + [ + 10, + 1 + ], + [ + 18, + 2 + ] + ] + } + }, + "filter": [ + "==", + "$type", + "LineString" + ] + }, + { + "id": "Ocean labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "water_name", + "minzoom": 0, + "layout": { + "symbol-placement": "point", + "text-field": "{name:en}", + "text-font": [ + "Roboto Italic", + "Noto Sans Italic" + ], + "text-max-width": 5, + "text-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 1, + [ + "match", + [ + "get", + "class" + ], + [ + "ocean" + ], + 14, + 10 + ], + 3, + [ + "match", + [ + "get", + "class" + ], + [ + "ocean" + ], + 18, + 14 + ], + 9, + [ + "match", + [ + "get", + "class" + ], + [ + "ocean" + ], + 22, + 18 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "lake" + ], + 14, + [ + "sea" + ], + 20, + 26 + ] + ], + "visibility": "visible" + }, + "paint": { + "text-color": { + "stops": [ + [ + 1, + "hsl(203,54%,54%)" + ], + [ + 4, + "hsl(203,72%,39%)" + ] + ] + }, + "text-halo-blur": 1, + "text-halo-color": { + "stops": [ + [ + 1, + "hsla(196, 72%, 80%, 0.05)" + ], + [ + 3, + "hsla(200, 100%, 88%, 0.75)" + ] + ] + }, + "text-halo-width": 1, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 1, + [ + "match", + [ + "get", + "class" + ], + [ + "ocean" + ], + 1, + 0 + ], + 3, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "has", + "name" + ], + [ + "!=", + "class", + "lake" + ] + ] + }, + { + "id": "Lake labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "water_name", + "minzoom": 0, + "layout": { + "symbol-placement": "line", + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Italic", + "Noto Sans Italic" + ], + "text-letter-spacing": 0.1, + "text-max-width": 5, + "text-size": { + "stops": [ + [ + 10, + 13 + ], + [ + 14, + 16 + ], + [ + 22, + 20 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(205,84%,39%)", + "text-halo-color": "hsla(0, 100%, 100%, 0.45)", + "text-halo-width": 1.5 + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "lake" + ] + ] + }, + { + "id": "Housenumber", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "housenumber", + "minzoom": 18, + "layout": { + "text-field": "{housenumber}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-size": 10, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(26,10%,44%)", + "text-halo-blur": 1, + "text-halo-color": "hsl(21,64%,96%)", + "text-halo-width": 1 + } + }, + { + "id": "Gondola", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "text-anchor": "center", + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Italic", + "Noto Sans Italic" + ], + "text-offset": [ + 0.8, + 0.8 + ], + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 13 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(0,0%,40%)", + "text-halo-blur": 1, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1 + }, + "metadata": {}, + "filter": [ + "in", + "subclass", + "gondola", + "cable_car" + ] + }, + { + "id": "Ferry", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "text-anchor": "center", + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Italic", + "Noto Sans Italic" + ], + "text-offset": [ + 0.8, + 0.8 + ], + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 11 + ], + [ + 15, + 12 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(205,84%,39%)", + "text-halo-blur": 0.5, + "text-halo-color": "hsla(0, 0%, 100%, 0.15)", + "text-halo-width": 1 + }, + "filter": [ + "==", + "class", + "ferry" + ] + }, + { + "id": "Oneway", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 16, + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": [ + "match", + [ + "get", + "oneway" + ], + 1, + 0, + 0 + ], + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [ + 16, + 0.7 + ], + [ + 19, + 1 + ] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75, + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 0%, 65%)", + "icon-opacity": 0.5 + }, + "filter": [ + "all", + [ + "has", + "oneway" + ], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ] + }, + { + "id": "Road labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 8, + "layout": { + "icon-allow-overlap": false, + "icon-ignore-placement": false, + "icon-keep-upright": false, + "symbol-placement": "line", + "symbol-spacing": [ + "step", + [ + "zoom" + ], + 250, + 21, + 1000 + ], + "text-allow-overlap": false, + "text-anchor": "center", + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-ignore-placement": false, + "text-justify": "center", + "text-max-width": 10, + "text-offset": [ + 0, + 0.15 + ], + "text-optional": false, + "text-size": { + "stops": [ + [ + 13, + 10 + ], + [ + 14, + 11 + ], + [ + 18, + 13 + ], + [ + 22, + 15 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 0%, 16%)", + "text-color": "hsl(0, 0%, 16%)", + "text-halo-blur": 0.5, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1 + }, + "metadata": {}, + "filter": [ + "all", + [ + "!in", + "subclass", + "gondola", + "cable_car" + ], + [ + "!in", + "class", + "ferry", + "service" + ] + ] + }, + { + "id": "Highway junction", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 16, + "layout": { + "icon-image": "exit_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-avoid-edges": true, + "symbol-placement": "point", + "symbol-spacing": 200, + "symbol-z-order": "auto", + "text-field": "{ref}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-size": 9, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(0,0%,21%)", + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1 + }, + "filter": [ + "all", + [ + ">", + "ref_length", + 0 + ], + [ + "==", + "$type", + "Point" + ], + [ + "==", + "subclass", + "junction" + ] + ] + }, + { + "id": "Highway shield", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 8, + "layout": { + "icon-image": "road_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-avoid-edges": true, + "symbol-placement": "line", + "symbol-spacing": { + "stops": [ + [ + 10, + 200 + ], + [ + 18, + 400 + ] + ] + }, + "text-field": "{ref}", + "text-font": [ + "match", + [ + "get", + "class" + ], + "motorway", + [ + "literal", + [ + "Roboto Bold", + "Noto Sans Bold" + ] + ], + [ + "literal", + [ + "Roboto Regular" + ] + ] + ], + "text-offset": [ + 0, + 0.05 + ], + "text-padding": 2, + "text-rotation-alignment": "viewport", + "text-size": 10, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 0%, 100%)", + "icon-halo-color": "hsl(0, 0%, 29%)", + "icon-halo-width": 1, + "text-color": "hsl(0, 0%, 29%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1 + }, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "network", + "us-interstate", + "us-highway", + "us-state" + ], + [ + "!in", + "class", + "path" + ] + ] + }, + { + "id": "Highway shield (US)", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 7, + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1.1, + "symbol-avoid-edges": true, + "symbol-placement": { + "base": 1, + "stops": [ + [ + 7, + "point" + ], + [ + 7, + "line" + ], + [ + 8, + "line" + ] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": [ + "match", + [ + "get", + "class" + ], + "motorway", + [ + "literal", + [ + "Roboto Bold", + "Noto Sans Bold" + ] + ], + [ + "literal", + [ + "Roboto Regular", + "Noto Sans Regular" + ] + ] + ], + "text-offset": [ + 0, + 0.05 + ], + "text-rotation-alignment": "viewport", + "text-size": 9, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 0%, 100%)", + "icon-halo-color": "hsl(0, 0%, 29%)", + "icon-halo-width": 1, + "text-color": "hsl(0, 0%, 29%)", + "text-halo-color": "rgba(255, 255, 255, 1)", + "text-halo-width": 0 + }, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "network", + "us-highway", + "us-state" + ], + [ + "!in", + "class", + "path" + ] + ] + }, + { + "id": "Highway shield interstate top (US)", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 7, + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [ + 7, + "point" + ], + [ + 7, + "line" + ], + [ + 8, + "line" + ] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": [ + "match", + [ + "get", + "class" + ], + "motorway", + [ + "literal", + [ + "Roboto Bold", + "Noto Sans Bold" + ] + ], + [ + "literal", + [ + "Roboto Regular", + "Noto Sans Regular" + ] + ] + ], + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-size": 9, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(21, 100%, 45%)", + "icon-halo-color": "rgba(255, 255, 255, 1)", + "icon-halo-width": 1, + "icon-translate": [ + 0, + -4 + ], + "icon-translate-anchor": "viewport", + "text-color": "hsl(21, 100%, 45%)", + "text-halo-color": "rgba(255, 255, 255, 1)", + "text-halo-width": 0 + }, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "network", + "us-interstate" + ], + [ + "!in", + "class", + "path" + ] + ] + }, + { + "id": "Highway shield interstate (US)", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 7, + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [ + 7, + "point" + ], + [ + 7, + "line" + ], + [ + 8, + "line" + ] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": [ + "match", + [ + "get", + "class" + ], + "motorway", + [ + "literal", + [ + "Roboto Bold", + "Noto Sans Bold" + ] + ], + [ + "literal", + [ + "Roboto Regular", + "Noto Sans Regular" + ] + ] + ], + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-size": 9, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(212, 79%, 42%)", + "icon-halo-color": "rgba(255, 255, 255, 1)", + "icon-halo-width": 1, + "text-color": "hsl(0, 0%, 100%)", + "text-halo-color": "rgba(255, 255, 255, 1)", + "text-halo-width": 0, + "text-translate": [ + 0, + -0.5 + ] + }, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "network", + "us-interstate" + ], + [ + "!in", + "class", + "path" + ] + ] + }, + { + "id": "Public", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 16, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + [ + "atm", + "bank", + "bbq", + "cemetery", + "courthouse", + "drinking_water", + "fire_station", + "fountain", + "hairdresser", + "office", + "post", + "prison", + "recycling", + "shower", + "telephone", + "toilets", + "townhall", + "town_hall" + ], + [ + "get", + "class" + ], + [ + "case", + [ + "has", + "class" + ], + "", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(51, 10%, 40%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "atm", + "bank", + "cemetery", + "courthouse", + "townhall", + "town_hall" + ], + 1, + 0 + ], + 17, + [ + "match", + [ + "get", + "class" + ], + [ + "atm", + "bank", + "cemetery", + "courthouse", + "fire_station", + "townhall", + "town_hall", + "post" + ], + 1, + 0 + ], + 18, + 1 + ], + "text-color": "hsl(51, 10%, 40%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "atm", + "bank", + "cemetery", + "courthouse", + "townhall", + "town_hall" + ], + 1, + 0 + ], + 17, + [ + "match", + [ + "get", + "class" + ], + [ + "atm", + "bank", + "cemetery", + "courthouse", + "fire_station", + "townhall", + "town_hall", + "post" + ], + 1, + 0 + ], + 18, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "atm", + "bank", + "bbq", + "cemetery", + "courthouse", + "drinking_water", + "fire_station", + "fountain", + "hairdresser", + "office", + "post", + "prison", + "recycling", + "shower", + "telephone", + "toilets", + "townhall", + "town_hall" + ] + ] + }, + { + "id": "Sport", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 16, + "layout": { + "icon-image": [ + "coalesce", + [ + "image", + [ + "to-string", + [ + "get", + "subclass" + ] + ] + ], + [ + "image", + [ + "get", + "class" + ] + ], + [ + "image", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(129, 65%, 30%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "playground", + "pitch", + "stadium", + "sports_hall", + "swimming_pool" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(129, 65%, 30%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "playground", + "pitch", + "stadium", + "sports_hall", + "swimming_pool" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "american_football", + "athletics", + "archery", + "baseball", + "basketball", + "climbing", + "equestrian", + "fitness", + "fitness_centre", + "golf", + "motor", + "multi", + "playground", + "pitch", + "running", + "sauna", + "soccer", + "sport", + "stadium", + "sports_centre", + "sports_hall", + "swimming", + "swimming_area", + "swimming_pool", + "tennis", + "volleyball", + "water_park" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Education", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 15, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + [ + "college", + "childcare", + "dancing_school", + "driving_school", + "kindergarten", + "school", + "university" + ], + [ + "get", + "class" + ], + [ + "case", + [ + "has", + "class" + ], + "", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(175, 50%, 40%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 15, + [ + "match", + [ + "get", + "subclass" + ], + [ + "university" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "school", + "university" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(175, 50%, 40%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 15, + [ + "match", + [ + "get", + "subclass" + ], + [ + "university" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "school", + "university" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "college", + "childcare", + "dancing_school", + "driving_school", + "kindergarten", + "school", + "university" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Tourism", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + [ + "apartment", + "aquarium", + "attraction", + "campsite", + "camp_site", + "caravan_site", + "castle", + "chalet", + "guest_house", + "hotel", + "hostel", + "information", + "lodging", + "motel", + "reservoir", + "ruins", + "theme_park", + "zoo" + ], + [ + "get", + "class" + ], + [ + "case", + [ + "has", + "class" + ], + "", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(283, 55%, 35%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "attraction" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "attraction", + "castle", + "hotel", + "zoo" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(283, 55%, 35%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "attraction" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "attraction", + "castle", + "hotel", + "zoo" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "apartment", + "aquarium", + "attraction", + "campsite", + "camp_site", + "caravan_site", + "castle", + "chalet", + "guest_house", + "hotel", + "hostel", + "information", + "lodging", + "motel", + "reservoir", + "ruins", + "theme_park", + "zoo" + ], + [ + "!=", + "subclass", + "board" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Culture", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "icon-image": [ + "coalesce", + [ + "image", + [ + "to-string", + [ + "get", + "subclass" + ] + ] + ], + [ + "image", + [ + "get", + "class" + ] + ], + [ + "image", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(315, 35%, 50%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "museum" + ], + 1, + 0 + ], + 15, + [ + "match", + [ + "get", + "class" + ], + [ + "cinema", + "art_gallery", + "museum", + "theatre" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "cinema", + "art_gallery", + "library", + "museum", + "place_of_worship", + "theatre" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(315, 35%, 50%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "museum" + ], + 1, + 0 + ], + 15, + [ + "match", + [ + "get", + "class" + ], + [ + "cinema", + "art_gallery", + "museum", + "theatre" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "cinema", + "art_gallery", + "library", + "museum", + "place_of_worship", + "theatre" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "art_gallery", + "archeological_site", + "cinema", + "community_centre", + "gallery", + "library", + "monastery", + "monument", + "museum", + "opera", + "place_of_worship", + "planetarium", + "theatre" + ], + [ + "!=", + "subclass", + "artwork" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Shopping", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "icon-image": [ + "coalesce", + [ + "image", + [ + "to-string", + [ + "get", + "subclass" + ] + ] + ], + [ + "image", + [ + "get", + "class" + ] + ], + [ + "image", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(18, 17%, 30%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "mall" + ], + 1, + 0 + ], + 15, + [ + "match", + [ + "get", + "subclass" + ], + [ + "mall", + "supermarket" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "bakery", + "chemist", + "mall", + "supermarket" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(18, 17%, 30%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "mall" + ], + 1, + 0 + ], + 15, + [ + "match", + [ + "get", + "subclass" + ], + [ + "mall", + "supermarket" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "bakery", + "chemist", + "mall", + "supermarket" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "all", + [ + "in", + "class", + "alcohol_shop", + "bakery", + "book", + "books", + "butcher", + "chemist", + "clothing_store", + "convenience", + "gift", + "grocery", + "laundry", + "mall", + "music", + "shop", + "supermarket" + ], + [ + "has", + "name" + ] + ] + ] + }, + { + "id": "Food", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "maxzoom": 22, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + [ + "bar", + "beer", + "cafe", + "fast_food", + "ice_cream", + "restaurant" + ], + [ + "get", + "class" + ], + [ + "biergarten", + "pub" + ], + "beer", + "", + [ + "get", + "class" + ], + [ + "food_court" + ], + "restaurant", + "dot" + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(18, 24%, 44%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 20 + ], + 1, + 0 + ], + 15, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 99 + ], + 1, + 0 + ], + 16, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 499 + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(18, 24%, 44%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 20 + ], + 1, + 0 + ], + 15, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 99 + ], + 1, + 0 + ], + 16, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 499 + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "bar", + "beer", + "biergarten", + "cafe", + "fast_food", + "food_court", + "ice_cream", + "pub", + "restaurant" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Transport", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + [ + "aerialway", + "bicycle", + "bicycle_parking", + "car", + "car_rental", + "car_repair", + "charging_station", + "cycle_barrier", + "ferry_terminal", + "fuel", + "harbor", + "motorcycle_parking", + "parking", + "parking_garage", + "parking_paid" + ], + [ + "get", + "class" + ], + [ + "case", + [ + "has", + "class" + ], + "dot", + "" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(215, 81%, 35%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "ferry_terminal", + "fuel", + "terminal" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "car", + "car_rental", + "car_repair", + "charging_station", + "ferry_terminal", + "fuel", + "harbor", + "heliport", + "highway_rest_area", + "terminal", + "toll" + ], + 1, + 0 + ], + 17, + [ + "match", + [ + "get", + "class" + ], + [ + "car", + "car_rental", + "car_repair", + "charging_station", + "ferry_terminal", + "fuel", + "harbor", + "heliport", + "highway_rest_area", + "parking", + "parking_garage", + "parking_paid", + "terminal", + "toll" + ], + 1, + 0 + ], + 18, + 1 + ], + "text-color": "hsl(215, 81%, 35%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "ferry_terminal", + "fuel", + "terminal" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "car", + "car_rental", + "car_repair", + "charging_station", + "ferry_terminal", + "fuel", + "harbor", + "heliport", + "highway_rest_area", + "terminal", + "toll" + ], + 1, + 0 + ], + 17, + [ + "match", + [ + "get", + "class" + ], + [ + "car", + "car_rental", + "car_repair", + "charging_station", + "ferry_terminal", + "fuel", + "harbor", + "heliport", + "highway_rest_area", + "parking", + "parking_garage", + "parking_paid", + "terminal", + "toll" + ], + 1, + 0 + ], + 18, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "bicycle", + "bicycle_parking", + "bicycle_rental", + "car", + "car_rental", + "car_repair", + "charging_station", + "ferry_terminal", + "fuel", + "harbor", + "heliport", + "highway_rest_area", + "motorcycle_parking", + "parking", + "parking_garage", + "parking_paid", + "scooter", + "terminal", + "toll" + ] + ] + }, + { + "id": "Park", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "icon-anchor": "center", + "icon-image": "park", + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(82, 83%, 25%)", + "icon-halo-blur": 0, + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 10 + ], + 1, + 0 + ], + 15, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 99 + ], + 1, + 0 + ], + 16, + 1 + ], + "text-color": "hsl(82, 83%, 25%)", + "text-halo-blur": 0, + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 10 + ], + 1, + 0 + ], + 15, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 99 + ], + 1, + 0 + ], + 16, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "class", + "park" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Healthcare", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + [ + "clinic", + "dentist", + "doctors", + "doctor", + "first_aid", + "hospital", + "pharmacy", + "veterinary" + ], + [ + "get", + "class" + ], + [ + "case", + [ + "has", + "class" + ], + "", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(6, 96%, 35%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "hospital" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "clinic", + "hospital" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(6, 96%, 35%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "hospital" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "clinic", + "hospital" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "clinic", + "dentist", + "doctors", + "first_aid", + "hospital", + "pharmacy", + "veterinary" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Place labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "minzoom": 4, + "layout": { + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "center", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-letter-spacing": [ + "match", + [ + "get", + "class" + ], + [ + "suburb", + "neighborhood", + "neighbourhood", + "quarter", + "island" + ], + 0.2, + 0 + ], + "text-max-width": [ + "match", + [ + "get", + "class" + ], + [ + "island" + ], + 6, + 8 + ], + "text-offset": [ + 0, + 0 + ], + "text-padding": 2, + "text-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 3, + 11, + 8, + 13, + 11, + [ + "match", + [ + "get", + "class" + ], + "village", + 12, + [ + "suburb", + "neighbourhood", + "quarter", + "hamlet", + "isolated_dwelling" + ], + 9, + "island", + 8, + 12 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + "village", + 18, + [ + "suburb", + "neighbourhood", + "quarter", + "hamlet", + "isolated_dwelling" + ], + 15, + "island", + 11, + 16 + ] + ], + "text-transform": [ + "match", + [ + "get", + "class" + ], + [ + "suburb", + "neighborhood", + "neighbourhood", + "quarter", + "island" + ], + "uppercase", + "none" + ], + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(0,0%,25%)", + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1.2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 1, + 8, + [ + "match", + [ + "get", + "class" + ], + [ + "island" + ], + 0, + 1 + ], + 9, + [ + "match", + [ + "get", + "class" + ], + [ + "island" + ], + 1, + 1 + ] + ] + }, + "metadata": {}, + "filter": [ + "!in", + "class", + "continent", + "country", + "state", + "region", + "province", + "city", + "town", + "place" + ] + }, + { + "id": "Station", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 12, + "layout": { + "icon-image": [ + "match", + [ + "get", + "subclass" + ], + [ + "bus_stop", + "subway" + ], + [ + "get", + "subclass" + ], + "bus_station", + "bus_stop", + "station", + "railway", + "tram_stop", + "tramway", + [ + "case", + [ + "has", + "subclass" + ], + "dot", + "" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-line-height": 0.9, + "text-max-width": 9, + "text-offset": [ + 0, + 0.9 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 13, + 11 + ], + [ + 16, + 12 + ], + [ + 22, + 16 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(215, 83%, 53%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 12, + [ + "match", + [ + "get", + "subclass" + ], + [ + "station" + ], + 1, + 0 + ], + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "station", + "subway", + "tram_stop" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "bus_stop", + "station", + "subway", + "tram_stop" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(215, 83%, 53%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 12, + [ + "match", + [ + "get", + "subclass" + ], + [ + "station" + ], + 1, + 0 + ], + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "station", + "subway", + "tram_stop" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "bus_stop", + "station", + "subway", + "tram_stop" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "in", + "class", + "bus", + "railway" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Airport gate", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "aeroway", + "minzoom": 15, + "layout": { + "text-field": "{ref}", + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 22, + 18 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(0,0%,40%)", + "text-halo-blur": 0.5, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1 + }, + "filter": [ + "==", + "class", + "gate" + ] + }, + { + "id": "Airport", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "aerodrome_label", + "minzoom": 8, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + "international", + "airport", + "airfield" + ], + "icon-size": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 8, + 0.6, + 10, + [ + "match", + [ + "get", + "class" + ], + "international", + 0.8, + 0.6 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + "international", + 1, + 0.8 + ] + ], + "text-anchor": "top", + "text-field": { + "stops": [ + [ + 8, + " " + ], + [ + 9, + "{iata}" + ], + [ + 12, + "{name:en}" + ] + ] + }, + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-line-height": 1.2, + "text-max-width": 9, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 9, + 9, + 10, + [ + "match", + [ + "get", + "class" + ], + "international", + 10, + 7 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + "international", + 13, + 11 + ] + ], + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(215, 83%, 53%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 8, + 1, + 10, + 0.5, + 12, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 8, + 1, + 12, + 2 + ], + "icon-opacity": 1, + "text-color": "hsl(215, 83%, 53%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 8, + 1, + 10, + 0.5, + 12, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 8, + 1, + 12, + 2 + ] + }, + "filter": [ + "all", + [ + "has", + "iata" + ], + [ + "!in", + "class", + "public" + ] + ] + }, + { + "id": "State labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "minzoom": 3, + "maxzoom": 9, + "layout": { + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-letter-spacing": 0.1, + "text-max-width": 8, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 3, + 9 + ], + [ + 5, + 10 + ], + [ + 6, + 11 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(48,4%,44%)", + "text-halo-color": "hsla(0,0%,100%,0.75)", + "text-halo-width": 0.8, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 3, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 3 + ], + 1, + 0 + ], + 8, + [ + "case", + [ + "==", + [ + "get", + "rank" + ], + 0 + ], + 0, + 1 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "in", + "class", + "state", + "province" + ], + [ + "<=", + "rank", + 6 + ] + ] + }, + { + "id": "Town labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "minzoom": 4, + "maxzoom": 16, + "layout": { + "icon-allow-overlap": true, + "icon-image": { + "stops": [ + [ + 6, + "circle" + ], + [ + 12, + " " + ] + ] + }, + "icon-optional": false, + "icon-size": [ + "interpolate", + [ + "exponential", + 1 + ], + [ + "zoom" + ], + 6, + 0.3, + 14, + 0.4 + ], + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "bottom", + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + -0.15 + ], + "text-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 6, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 12 + ], + 11, + 10 + ], + 9, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 15 + ], + 13, + 12 + ], + 16, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 15 + ], + 22, + 20 + ] + ], + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 20%, 99%)", + "icon-halo-color": "hsl(0, 0%, 29%)", + "icon-halo-width": 1, + "text-color": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 6, + "hsl(0,0%,20%)", + 12, + "hsl(0,0%,0%)" + ], + "text-halo-blur": 0.5, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1 + }, + "metadata": {}, + "filter": [ + "==", + "class", + "town" + ] + }, + { + "id": "City labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "minzoom": 4, + "maxzoom": 16, + "layout": { + "icon-allow-overlap": true, + "icon-image": [ + "step", + [ + "zoom" + ], + "circle", + 13, + "" + ], + "icon-optional": false, + "icon-size": 0.4, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "bottom", + "text-field": "{name:en}", + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + -0.15 + ], + "text-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 4, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 2 + ], + 14, + 12 + ], + 8, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 4 + ], + 18, + 14 + ], + 12, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 4 + ], + 24, + 18 + ], + 16, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 4 + ], + 32, + 26 + ] + ], + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 0%, 100%)", + "icon-halo-color": "hsl(0, 0%, 29%)", + "icon-halo-width": 1, + "icon-opacity": 1, + "text-color": "hsl(0,0%,20%)", + "text-halo-blur": 0.5, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 0.8 + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + "!=", + "capital", + 2 + ] + ] + }, + { + "id": "Capital city labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "minzoom": 4, + "maxzoom": 16, + "layout": { + "icon-allow-overlap": true, + "icon-image": [ + "step", + [ + "zoom" + ], + "circle", + 13, + "" + ], + "icon-optional": false, + "icon-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 4, + 0.45, + 10, + 0.5, + 11, + 0.6 + ], + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "bottom", + "text-field": "{name:en}", + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + -0.15 + ], + "text-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 4, + 14, + 8, + 18, + 12, + 24, + 16, + 32 + ], + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 0%, 100%)", + "icon-halo-color": "hsl(0, 0%, 29%)", + "icon-halo-width": 1, + "icon-opacity": 1, + "text-color": "hsl(0,0%,20%)", + "text-halo-blur": 0.5, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 0.8 + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + "==", + "capital", + 2 + ] + ] + }, + { + "id": "Country labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "minzoom": 1, + "maxzoom": 12, + "layout": { + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-allow-overlap": false, + "text-field": "{name:en}", + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-letter-spacing": 0.07, + "text-max-width": { + "stops": [ + [ + 1, + 5 + ], + [ + 5, + 8 + ] + ] + }, + "text-padding": 1, + "text-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 0, + 8, + 1, + 10, + 4, + [ + "case", + [ + ">", + [ + "get", + "rank" + ], + 2 + ], + 15, + 17 + ], + 8, + [ + "case", + [ + ">", + [ + "get", + "rank" + ], + 2 + ], + 19, + 23 + ] + ], + "text-transform": "none", + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(0, 0%, 20%)", + "text-halo-blur": 0.8, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1, + "text-opacity": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 4, + [ + "case", + [ + ">", + [ + "get", + "rank" + ], + 4 + ], + 0, + 1 + ], + 5.9, + [ + "case", + [ + ">", + [ + "get", + "rank" + ], + 4 + ], + 0, + 1 + ], + 6, + [ + "case", + [ + ">", + [ + "get", + "rank" + ], + 4 + ], + 1, + 1 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + "has", + "iso_a2" + ], + [ + "!=", + "iso_a2", + "VA" + ] + ] + }, + { + "id": "Continent labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "maxzoom": 1, + "layout": { + "text-field": "{name:en}", + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-justify": "center", + "text-size": { + "stops": [ + [ + 0, + 12 + ], + [ + 2, + 13 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(0,0%,19%)", + "text-halo-blur": 1, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1 + }, + "metadata": {}, + "filter": [ + "==", + "class", + "continent" + ] + } + ], + "metadata": { + "maptiler:copyright": "You are licensed to use the style or its derivate for serving map tiles exclusively with MapTiler Server or MapTiler Cloud and in accordance with their licenses and terms. If you plan to use the style in a different way, contact us at sales@maptiler.com.", + "spaceColor": "hsl(203, 100%, 85%)" + }, + "glyphs": "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key=Tp9K7a8qzMTkBSzO4EZb", + "sprite": "https://api.maptiler.com/maps/streets-v2/sprite", + "bearing": 0, + "pitch": 0, + "center": [ + 0, + 0 + ], + "zoom": 1 +} \ No newline at end of file diff --git a/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/ui/NavGraph.kt b/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/ui/NavGraph.kt index b464a151..a2b314db 100644 --- a/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/ui/NavGraph.kt +++ b/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/ui/NavGraph.kt @@ -23,6 +23,8 @@ import ovh.plrapps.mapcompose.demo.ui.screens.MarkersLazyLoadingDemo import ovh.plrapps.mapcompose.demo.ui.screens.OsmDemo import ovh.plrapps.mapcompose.demo.ui.screens.PathsDemo import ovh.plrapps.mapcompose.demo.ui.screens.RotationDemo +import ovh.plrapps.mapcompose.demo.ui.screens.SwissMapVectorDemo +import ovh.plrapps.mapcompose.demo.ui.screens.VectorDemo import ovh.plrapps.mapcompose.demo.ui.screens.VisibleAreaPaddingDemo /* Type-safe navigation routes. Each demo screen and the home list has its own route object. */ @@ -42,6 +44,8 @@ import ovh.plrapps.mapcompose.demo.ui.screens.VisibleAreaPaddingDemo @Serializable object VisibleAreaPaddingRoute @Serializable object MarkersClusteringRoute @Serializable object MarkersLazyLoadingRoute +@Serializable object VectorRoute +@Serializable object SwissMapVectorRoute enum class MainDestinations(val title: String, val route: Any) { MAP_ALONE("Simple map", MapAlone), @@ -58,7 +62,9 @@ enum class MainDestinations(val title: String, val route: Any) { HTTP_TILES_DEMO("Remote HTTP tiles", HttpTilesRoute), VISIBLE_AREA_PADDING("Visible area padding", VisibleAreaPaddingRoute), MARKERS_CLUSTERING("Markers clustering", MarkersClusteringRoute), - MARKERS_LAZY_LOADING("Markers lazy loading", MarkersLazyLoadingRoute); + MARKERS_LAZY_LOADING("Markers lazy loading", MarkersLazyLoadingRoute), + VECTOR_TILE("Vector tile ", VectorRoute), + SWISS_VECTOR_TILE("Swiss map vector tile", SwissMapVectorRoute); } /** @@ -82,6 +88,8 @@ fun NavGraphBuilder.demoDestinations() { composable { VisibleAreaPaddingDemo.Content() } composable { MarkersClusteringDemo.Content() } composable { MarkersLazyLoadingDemo.Content() } + composable { VectorDemo.Content() } + composable { SwissMapVectorDemo.Content() } } /** diff --git a/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/SwissMapVectorDemo.kt b/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/SwissMapVectorDemo.kt new file mode 100644 index 00000000..e1619de4 --- /dev/null +++ b/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/SwissMapVectorDemo.kt @@ -0,0 +1,19 @@ +package ovh.plrapps.mapcompose.demo.ui.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import ovh.plrapps.mapcompose.demo.viewmodels.SwissMapVectorDemoVM +import ovh.plrapps.mapcompose.ui.MapUI + +expect object SwissMapVectorDemo { + @Composable + fun Content() +} + +@Composable +fun SwissMapVectorCommonUi(screenModel: SwissMapVectorDemoVM) { + MapUI( + Modifier, + state = screenModel.state + ) +} \ No newline at end of file diff --git a/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.kt b/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.kt new file mode 100644 index 00000000..a6ba54f3 --- /dev/null +++ b/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.kt @@ -0,0 +1,19 @@ +package ovh.plrapps.mapcompose.demo.ui.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import ovh.plrapps.mapcompose.demo.viewmodels.VectorDemoVM +import ovh.plrapps.mapcompose.ui.MapUI + +expect object VectorDemo { + @Composable + fun Content() +} + +@Composable +fun VectorCommonUi(screenModel: VectorDemoVM) { + MapUI( + Modifier, + state = screenModel.state + ) +} \ No newline at end of file diff --git a/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/SwissMapVectorDemoVM.kt b/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/SwissMapVectorDemoVM.kt new file mode 100644 index 00000000..d132495b --- /dev/null +++ b/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/SwissMapVectorDemoVM.kt @@ -0,0 +1,52 @@ +package ovh.plrapps.mapcompose.demo.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import ovh.plrapps.mapcompose.api.addVectorLayer +import ovh.plrapps.mapcompose.api.enableRotation +import ovh.plrapps.mapcompose.api.scale +import ovh.plrapps.mapcompose.api.shouldLoopScale +import ovh.plrapps.mapcompose.ui.layout.Forced +import ovh.plrapps.mapcompose.ui.state.MapState +import kotlin.math.pow + +/** + * Shows how MapCompose behaves with remote HTTP tiles. + */ +class SwissMapVectorDemoVM : ViewModel() { + private val vectorTileStreamProvider = OSMVectorTileStreamProvider( + styleUrl = "https://vectortiles.geo.admin.ch/styles/ch.swisstopo.lightbasemap.vt/style.json" // ch.swisstopo.basemap.vt + ) + + private val maxLevel = 15 + private val minLevel = 7 + private val tileSize = 512 + private val mapSize = mapSizeAtLevel(maxLevel, tileSize = tileSize) + + val state = MapState( + levelCount = maxLevel + 1, + fullWidth = mapSize, + fullHeight = mapSize, + workerCount = 16, + tileSize = tileSize + ) { + minimumScaleMode(Forced(1 / 2.0.pow(maxLevel - minLevel))) + scroll(0.5231943373, 0.3542287256) + }.apply { + viewModelScope.launch { + addVectorLayer(vectorTileStreamProvider) + } + enableRotation() + scale = 0.0 + shouldLoopScale = true + } +} + +/** + * wmts level are 0 based. + * At level 0, the map corresponds to just one tile. + */ +private fun mapSizeAtLevel(wmtsLevel: Int, tileSize: Int): Int { + return tileSize * 2.0.pow(wmtsLevel).toInt() +} \ No newline at end of file diff --git a/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/VectorDemoVM.kt b/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/VectorDemoVM.kt new file mode 100644 index 00000000..c91785c2 --- /dev/null +++ b/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/VectorDemoVM.kt @@ -0,0 +1,61 @@ +package ovh.plrapps.mapcompose.demo.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import kotlinx.io.RawSource +import ovh.plrapps.mapcompose.api.addVectorLayer +import ovh.plrapps.mapcompose.api.enableRotation +import ovh.plrapps.mapcompose.api.scale +import ovh.plrapps.mapcompose.api.shouldLoopScale +import ovh.plrapps.mapcompose.vector.core.VectorTileStreamProvider +import ovh.plrapps.mapcompose.ui.state.MapState +import kotlin.math.pow + +/** + * Shows how MapCompose behaves with remote HTTP tiles. + */ +class VectorDemoVM : ViewModel() { + private val vectorTileStreamProvider = OSMVectorTileStreamProvider( + styleUrl = "files/style_street_v2.json" + ) + + private val maxLevel = 16 + private val mapSize = mapSizeAtLevel(maxLevel, tileSize = 256) + + val state = MapState( + levelCount = maxLevel + 1, + fullWidth = mapSize, + fullHeight = mapSize, + workerCount = 16 + ){ + infiniteScrollX(true) + }.apply { + viewModelScope.launch { + addVectorLayer(vectorTileStreamProvider) + } + enableRotation() + scale = 0.0 + shouldLoopScale = true + } +} + +expect class OSMVectorTileStreamProvider(styleUrl: String) : VectorTileStreamProvider { + override val styleUrl: String + + override suspend fun loadResources(url: String): RawSource? + override suspend fun getTileStream( + tileUrl: String, + row: Int, + col: Int, + zoomLvl: Int + ): RawSource? +} + +/** + * wmts level are 0 based. + * At level 0, the map corresponds to just one tile. + */ +private fun mapSizeAtLevel(wmtsLevel: Int, tileSize: Int): Int { + return tileSize * 2.0.pow(wmtsLevel).toInt() +} \ No newline at end of file diff --git a/demo/composeApp/src/desktopMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/SwissMapVectorDemo.desktop.kt b/demo/composeApp/src/desktopMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/SwissMapVectorDemo.desktop.kt new file mode 100644 index 00000000..11417cf9 --- /dev/null +++ b/demo/composeApp/src/desktopMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/SwissMapVectorDemo.desktop.kt @@ -0,0 +1,17 @@ +package ovh.plrapps.mapcompose.demo.ui.screens + +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel +import ovh.plrapps.mapcompose.demo.ui.MapWithZoomControl +import ovh.plrapps.mapcompose.demo.viewmodels.SwissMapVectorDemoVM + +actual object SwissMapVectorDemo { + @Composable + actual fun Content() { + val viewModel = viewModel { SwissMapVectorDemoVM() } + + MapWithZoomControl(state = viewModel.state) { + SwissMapVectorCommonUi(viewModel) + } + } +} \ No newline at end of file diff --git a/demo/composeApp/src/desktopMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.desktop.kt b/demo/composeApp/src/desktopMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.desktop.kt new file mode 100644 index 00000000..b7bf774a --- /dev/null +++ b/demo/composeApp/src/desktopMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.desktop.kt @@ -0,0 +1,17 @@ +package ovh.plrapps.mapcompose.demo.ui.screens + +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel +import ovh.plrapps.mapcompose.demo.ui.MapWithZoomControl +import ovh.plrapps.mapcompose.demo.viewmodels.VectorDemoVM + +actual object VectorDemo { + @Composable + actual fun Content() { + val viewModel = viewModel { VectorDemoVM() } + + MapWithZoomControl(state = viewModel.state) { + VectorCommonUi(viewModel) + } + } +} \ No newline at end of file diff --git a/demo/composeApp/src/desktopMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/Http.kt b/demo/composeApp/src/desktopMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/Http.kt index aec69332..b42f35e6 100644 --- a/demo/composeApp/src/desktopMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/Http.kt +++ b/demo/composeApp/src/desktopMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/Http.kt @@ -1,7 +1,12 @@ package ovh.plrapps.mapcompose.demo.viewmodels +import kotlinx.io.Buffer +import kotlinx.io.RawSource import kotlinx.io.asSource +import org.jetbrains.compose.resources.ExperimentalResourceApi import ovh.plrapps.mapcompose.core.TileStreamProvider +import ovh.plrapps.mapcompose.vector.core.VectorTileStreamProvider +import ovh.plrapps.mapcomposemp.demo.Res import java.net.HttpURLConnection import java.net.URL @@ -42,4 +47,51 @@ actual fun makeOsmTileStreamProvider() : TileStreamProvider { null } } +} + +actual class OSMVectorTileStreamProvider actual constructor(actual override val styleUrl: String) : + VectorTileStreamProvider { + + @OptIn(ExperimentalResourceApi::class) + actual override suspend fun loadResources(url: String): RawSource? { + return when (url) { + "files/style_street_v2.json" -> { + val buffer = Buffer() + buffer.write(Res.readBytes(url)) + buffer + } + else -> getResourceAsStream(url) + } + } + + actual override suspend fun getTileStream( + tileUrl: String, + row: Int, + col: Int, + zoomLvl: Int + ): RawSource? { + return getResourceAsStream(tileUrl) + } + + private fun getResourceAsStream(url: String): RawSource? { + return try { + val connection = URL(url).openConnection() as HttpURLConnection + connection.setRequestProperty("User-Agent", "Chrome/120.0.0.0 Safari/537.36") + // Explicitly request gzip — this disables Java's auto-decompression, so we + // decompress manually below when the server confirms gzip encoding. + connection.setRequestProperty("Accept-Encoding", "gzip") + connection.doInput = true + connection.connect() + + val stream = if (connection.contentEncoding?.equals("gzip", ignoreCase = true) == true) { + java.util.zip.GZIPInputStream(connection.inputStream) + } else { + connection.inputStream + } + stream.asSource() + } catch (e: Exception) { + e.printStackTrace() + null + } + } } \ No newline at end of file diff --git a/demo/composeApp/src/iosMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/SwissMapVectorDemo.ios.kt b/demo/composeApp/src/iosMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/SwissMapVectorDemo.ios.kt new file mode 100644 index 00000000..8901d691 --- /dev/null +++ b/demo/composeApp/src/iosMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/SwissMapVectorDemo.ios.kt @@ -0,0 +1,14 @@ +package ovh.plrapps.mapcompose.demo.ui.screens + +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel +import ovh.plrapps.mapcompose.demo.viewmodels.SwissMapVectorDemoVM + +actual object SwissMapVectorDemo { + @Composable + actual fun Content() { + val viewModel = viewModel { SwissMapVectorDemoVM() } + + SwissMapVectorCommonUi(viewModel) + } +} \ No newline at end of file diff --git a/demo/composeApp/src/iosMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.ios.kt b/demo/composeApp/src/iosMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.ios.kt new file mode 100644 index 00000000..429467dd --- /dev/null +++ b/demo/composeApp/src/iosMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.ios.kt @@ -0,0 +1,14 @@ +package ovh.plrapps.mapcompose.demo.ui.screens + +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel +import ovh.plrapps.mapcompose.demo.viewmodels.VectorDemoVM + +actual object VectorDemo { + @Composable + actual fun Content() { + val viewModel = viewModel { VectorDemoVM() } + + VectorCommonUi(viewModel) + } +} \ No newline at end of file diff --git a/demo/composeApp/src/iosMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/Http.kt b/demo/composeApp/src/iosMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/Http.kt index d697c278..f565808f 100644 --- a/demo/composeApp/src/iosMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/Http.kt +++ b/demo/composeApp/src/iosMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/Http.kt @@ -1,8 +1,13 @@ package ovh.plrapps.mapcompose.demo.viewmodels +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import org.jetbrains.compose.resources.ExperimentalResourceApi import ovh.plrapps.mapcompose.core.TileStreamProvider +import ovh.plrapps.mapcompose.vector.core.VectorTileStreamProvider import ovh.plrapps.mapcompose.demo.utils.getKtorClient import ovh.plrapps.mapcompose.demo.utils.readBuffer +import ovh.plrapps.mapcomposemp.demo.Res actual fun makeHttpTileStreamProvider(): TileStreamProvider { // TODO: use a blocking http client. MapCompose already switches context when calling the provided TileStreamProvider, @@ -33,4 +38,40 @@ actual fun makeOsmTileStreamProvider(): TileStreamProvider { null } } +} + +actual class OSMVectorTileStreamProvider actual constructor(actual override val styleUrl: String) : + VectorTileStreamProvider { + val httpClient = getKtorClient() + + @OptIn(ExperimentalResourceApi::class) + actual override suspend fun loadResources(url: String): RawSource? { + return try { + when (url) { + "files/style_street_v2.json" -> { + val buffer = Buffer() + buffer.write(Res.readBytes(url)) + buffer + } + else -> readBuffer(httpClient, url) + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + actual override suspend fun getTileStream( + tileUrl: String, + row: Int, + col: Int, + zoomLvl: Int + ): RawSource? { + return try { + readBuffer(httpClient, tileUrl) + } catch (e: Exception) { + e.printStackTrace() + null + } + } } \ No newline at end of file diff --git a/demo/composeApp/src/wasmJsMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/VectorDemoVM.wasmJs.kt b/demo/composeApp/src/wasmJsMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/VectorDemoVM.wasmJs.kt new file mode 100644 index 00000000..390b32ad --- /dev/null +++ b/demo/composeApp/src/wasmJsMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/VectorDemoVM.wasmJs.kt @@ -0,0 +1,44 @@ +package ovh.plrapps.mapcompose.demo.viewmodels + +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import org.jetbrains.compose.resources.ExperimentalResourceApi +import ovh.plrapps.mapcompose.vector.core.VectorTileStreamProvider +import ovh.plrapps.mapcompose.demo.utils.getKtorClient +import ovh.plrapps.mapcompose.demo.utils.readBuffer +import ovh.plrapps.mapcomposemp.demo.Res + +actual class OSMVectorTileStreamProvider actual constructor(actual override val styleUrl: String) : VectorTileStreamProvider { + val httpClient = getKtorClient() + + @OptIn(ExperimentalResourceApi::class) + actual override suspend fun loadResources(url: String): RawSource? { + return try { + when (url) { + "files/style_street_v2.json" -> { + val buffer = Buffer() + buffer.write(Res.readBytes(url)) + buffer + } + else -> readBuffer(httpClient, url) + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + actual override suspend fun getTileStream( + tileUrl: String, + row: Int, + col: Int, + zoomLvl: Int + ): RawSource? { + return try { + readBuffer(httpClient, tileUrl) + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} \ No newline at end of file diff --git a/doc/wip/trouble_direct_raster.mp4 b/doc/wip/trouble_direct_raster.mp4 new file mode 100644 index 00000000..207de402 Binary files /dev/null and b/doc/wip/trouble_direct_raster.mp4 differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e085e968..6323c71c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,11 +11,13 @@ jetbrains-compose = "1.11.1" # JetBrains Compose (KMP) compose = "1.11.2" # AndroidX Compose (Android-only) coroutines = "1.11.0" kotlinx-io = "0.9.0" +pbandk = "0.16.0" +kotlinx-datetime = "0.6.2" +kotlinx-serialization = "1.11.0" # ---- Demo ---- jetbrains-compose-material3 = "1.9.0" ktor = "3.4.3" -kotlinx-serialization = "1.11.0" jetbrains-navigation = "2.9.2" jetbrains-lifecycle = "2.11.0-beta01" androidx-activityCompose = "1.13.0" @@ -30,12 +32,16 @@ jetbrains-compose-foundation = { module = "org.jetbrains.compose.foundation:foun jetbrains-compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "jetbrains-compose" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" } +pbandk-runtime = { group = "pro.streem.pbandk", name = "pbandk-runtime", version.ref = "pbandk" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } # -- Android -- compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } # -- Test -- kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "jetbrains-compose" } # ============================== Demo ============================== # Dependencies the demo app adds on top of what the library already needs. @@ -50,6 +56,7 @@ jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifec kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } # -- Android -- androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } +androidx-core = { module = "androidx.core:core-ktx", version = "1.19.0" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } # -- iOS -- ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } @@ -69,7 +76,7 @@ vanniktechPublish = { id = "com.vanniktech.maven.publish", version = "0.36.0" } # Declared `apply false` in the root build but not applied by any module: androidLibrary = { id = "com.android.library", version.ref = "agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } # ---- Demo ---- androidApplication = { id = "com.android.application", version.ref = "agp" } -kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 898f520e..1d03ac11 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.jetbrainsCompose) alias(libs.plugins.composeCompiler) alias(libs.plugins.vanniktechPublish) + alias(libs.plugins.kotlinSerialization) } kotlin { @@ -71,13 +72,17 @@ kotlin { androidMain.dependencies { implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling) + implementation(libs.androidx.core) } commonMain.dependencies { implementation(libs.jetbrains.compose.runtime) implementation(libs.jetbrains.compose.foundation) implementation(libs.jetbrains.compose.ui) + implementation(libs.components.resources) implementation(libs.kotlinx.coroutines) api(libs.kotlinx.io.core) + implementation(libs.pbandk.runtime) + implementation(libs.kotlinx.serialization.json) } named("skiaMain").configure { dependencies { @@ -92,6 +97,9 @@ kotlin { commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) + implementation(libs.kotlinx.datetime) + + implementation(libs.compose.ui.test) } } } diff --git a/library/src/androidMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.android.kt b/library/src/androidMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.android.kt new file mode 100644 index 00000000..aaa4552a --- /dev/null +++ b/library/src/androidMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.android.kt @@ -0,0 +1,16 @@ +package ovh.plrapps.mapcompose.vector.data + +import android.graphics.BitmapFactory +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.graphics.createBitmap + +internal actual fun imageBitmapFromArgb(argb: IntArray, width: Int, height: Int): ImageBitmap { + val bmp = createBitmap(width, height) + bmp.setPixels(argb, 0, width, 0, 0, width, height) + return bmp.asImageBitmap() +} + +internal actual fun byteArrayToImageBitmap(bytes: ByteArray): ImageBitmap { + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size).asImageBitmap() +} \ No newline at end of file diff --git a/library/src/androidMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.android.kt b/library/src/androidMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.android.kt new file mode 100644 index 00000000..3279343b --- /dev/null +++ b/library/src/androidMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.android.kt @@ -0,0 +1,16 @@ +package ovh.plrapps.mapcompose.vector.data.extension + +import android.graphics.Bitmap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import java.io.ByteArrayOutputStream + +actual fun ImageBitmap.toBytes(): ByteArray? { + val bitmap = this.asAndroidBitmap() + val stream = ByteArrayOutputStream() + return if (bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)) { + stream.toByteArray() + } else { + null + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/api/LayerApi.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/api/LayerApi.kt index 3e7ee774..6c60adf5 100644 --- a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/api/LayerApi.kt +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/api/LayerApi.kt @@ -2,9 +2,18 @@ package ovh.plrapps.mapcompose.api -import ovh.plrapps.mapcompose.core.* +import ovh.plrapps.mapcompose.core.AboveAll +import ovh.plrapps.mapcompose.core.AboveLayer +import ovh.plrapps.mapcompose.core.BelowAll +import ovh.plrapps.mapcompose.core.BelowLayer +import ovh.plrapps.mapcompose.core.Layer +import ovh.plrapps.mapcompose.core.LayerPlacement +import ovh.plrapps.mapcompose.core.TileStreamProvider +import ovh.plrapps.mapcompose.vector.core.VectorTileStreamProvider +import ovh.plrapps.mapcompose.core.makeLayerId import ovh.plrapps.mapcompose.ui.state.MapState import ovh.plrapps.mapcompose.utils.swap +import ovh.plrapps.mapcompose.vector.core.VectorLayer /** @@ -55,6 +64,17 @@ fun MapState.addLayer( return id } +suspend fun MapState.addVectorLayer( + vectorTileStreamProvider: VectorTileStreamProvider, + initialOpacity: Float = 1f, + placement: LayerPlacement = AboveAll +): String { + val vectorLayer = VectorLayer(mapState = this, vectorTileStreamProvider) + val tileStreamProvider = vectorLayer.makeTileStreamProvider() + // TODO: honor placement parameter + return addLayer(tileStreamProvider) +} + /** * Replaces a layer. If the layer doesn't exist, no layer is added. * diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/MapUI.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/MapUI.kt index bee44821..41ceb39e 100644 --- a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/MapUI.kt +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/MapUI.kt @@ -7,12 +7,15 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.zIndex import ovh.plrapps.mapcompose.ui.layout.ZoomPanRotate import ovh.plrapps.mapcompose.ui.layout.ZoomPanRotateConfig import ovh.plrapps.mapcompose.ui.markers.MarkerComposer import ovh.plrapps.mapcompose.ui.paths.PathComposer import ovh.plrapps.mapcompose.ui.state.MapState +import ovh.plrapps.mapcompose.vector.ui.symbols.SymbolComposer import ovh.plrapps.mapcompose.ui.view.TileCanvas @Composable @@ -22,6 +25,7 @@ fun MapUI( content: @Composable () -> Unit = {} ) { val zoomPRState = state.zoomPanRotateState + val symbolState = state.symbolState val markerState = state.markerRenderState val pathState = state.pathState @@ -29,6 +33,14 @@ fun MapUI( remember(density) { state.densityState.value = density } + val fontFamilyResolver = LocalFontFamilyResolver.current + remember(fontFamilyResolver) { + state.fontFamilyResolverState.value = fontFamilyResolver + } + val textMeasurer = rememberTextMeasurer() + remember(textMeasurer) { + state.textMeasurerState.value = textMeasurer + } key(state) { ZoomPanRotate( @@ -55,6 +67,12 @@ fun MapUI( isFilteringBitmap = state.isFilteringBitmap, ) + SymbolComposer( + modifier = Modifier, + zoomPRState = zoomPRState, + symbolState = symbolState + ) + MarkerComposer( modifier = Modifier.zIndex(1f), zoomPRState = zoomPRState, diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/state/MapState.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/state/MapState.kt index 7198a88c..e449289a 100644 --- a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/state/MapState.kt +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/state/MapState.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.Density import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -23,6 +25,7 @@ import ovh.plrapps.mapcompose.ui.state.markers.MarkerRenderState import ovh.plrapps.mapcompose.ui.state.markers.MarkerState import ovh.plrapps.mapcompose.utils.AngleDegree import ovh.plrapps.mapcompose.utils.toRad +import ovh.plrapps.mapcompose.vector.ui.state.SymbolState /** * The state of the map. All public APIs are extensions functions or extension properties of this @@ -62,6 +65,7 @@ class MapState( internal val markerRenderState = MarkerRenderState() internal val markerState = MarkerState(scope, markerRenderState) internal val pathState = PathState(fullWidth, fullHeight) + internal val symbolState = SymbolState() internal val visibleTilesResolver = VisibleTilesResolver( levelCount = levelCount, @@ -73,6 +77,7 @@ class MapState( ) { zoomPanRotateState.scale } + internal val tileCanvasState = TileCanvasState( scope, tileSize, @@ -100,6 +105,8 @@ class MapState( applyLateInitialValues(initialValues) } internal val densityState = MutableStateFlow(null) + internal val fontFamilyResolverState = MutableStateFlow(null) + internal val textMeasurerState = MutableStateFlow(null) /** * Cancels all internal tasks. @@ -167,6 +174,14 @@ class MapState( private suspend fun renderVisibleTiles() { val viewport = updateViewport() tileCanvasState.setViewport(viewport) + for (listener in viewportListeners) { + listener.onViewportChanged(viewport) + } + } + + private var viewportListeners = mutableListOf() + internal fun addViewportChangeListener(listener: ViewportListener) { + viewportListeners.add(listener) } private fun updateViewport(): Viewport { @@ -337,4 +352,8 @@ class InitialValues internal constructor() { internal typealias LayoutTapCb = (x: Double, y: Double) -> Unit +internal fun interface ViewportListener { + fun onViewportChanged(viewport: Viewport) +} + expect fun getProcessorCount(): Int \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/state/TileCanvasState.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/state/TileCanvasState.kt index 9f9fe77e..68b316ed 100644 --- a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/state/TileCanvasState.kt +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/state/TileCanvasState.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import ovh.plrapps.mapcompose.core.ColorFilterProvider @@ -65,6 +66,10 @@ internal class TileCanvasState( private val visibleTileLocationsChannel = Channel(capacity = Channel.RENDEZVOUS) private val tilesOutput = Channel(capacity = Channel.RENDEZVOUS) private val visibleStateFlow = MutableStateFlow(null) + internal val visibleTiles = visibleStateFlow.asStateFlow().map { + it?.visibleTiles + } + internal var alphaTick = 0.07f set(value) { field = value.coerceIn(0.01f, 1f) diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/README.md b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/README.md new file mode 100644 index 00000000..c26059dd --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/README.md @@ -0,0 +1,104 @@ +## Specifications +- style-spec https://maplibre.org/maplibre-style-spec/ + - [Expressions](https://maplibre.org/maplibre-style-spec/expressions/) + - [Layers](https://maplibre.org/maplibre-style-spec/layers/) +- tilejson-spec https://github.com/mapbox/tilejson-spec/blob/master/2.2.0/README.md +- vector-tile-spec https://github.com/mapbox/vector-tile-spec/tree/master + +## Reference implementations : +- Maplibre-gl-js https://github.com/maplibre/maplibre-gl-js/tree/main/src/render +- Maplibre-native https://github.com/maplibre/maplibre-native/blob/main/src/mbgl/renderer/layers/render_symbol_layer.cpp + +## Status +The overall project structure and, most importantly, the parsers and decoders have been implemented. +For rendering, only the basic painters have been implemented (background, lines, polygons, labels). + +Three main directions +- Expression parser and processing. Needs to be covered with tests, and the existing tests require reorganization. +- Rendering.Implement the remaining painters and fix bugs in the existing ones. +- Tests. Not enough tests, more needed + +TL;DR +### ✅ Decoders + +- ✅ PBF decoder -> + - ✅ Geometry decoder +- ✅ Style decoder +- ✅ interpolation for: + - ✅ Color + - ✅ Number + - ✅ String +- ✅ Expressions (may require further analysis) +- ✅ Filters (MVP) + +### 🚧 Layers + +- 🛠 Background +- 🛠️ Lines +- 🛠️ Polygons +- 🛠️ Symbols +- ❌ Sprites (part of Symbols) +- ❌ Raster +- ❌ Circle +- ❌ FillExtrusion +- ❌ Heatmap +- ❌ Hillshade +- ❌ Sky + +### What is implemented and close to MapLibre + +#### MVT (Vector Tile) Geometry Decoding +ZigZag decoding, correct coordinate handling, support for POINT, LINESTRING, POLYGON. +Tests for the decoder and reversibility. + +#### Symbol Painter +Rendering text on lines and polygons. +Text centering on lines/polygons, angle calculation. +Correct text rotation (so it’s never upside down). +Support for text-halo (outline), color, size, opacity. +Support for text-offset, text-anchor. +Correct work with text templates (substituteTemplate). +Basic collision system implemented: labels do not overlap (within a tile). +Support for allowOverlap, ignorePlacement, priorities. +Visual debugging (borders, color). +Collision reset. +Currently, collision reset is implemented at the tile level, but MapLibre has nuances with global placement (especially when rendering multiple tiles in one frame). +No spatial index (R-tree), but for small tiles this is not critical. No Fonts(WIP). +#### Line, Background & Polygons +WIP +#### Styles +Using expressions (process()) to obtain styles. +LineLabelPlacement +Placing labels along a line, taking symbol-spacing into account. +Tests for even and correct placement. +Tests +Unit test coverage for decoder, collision, and line placement. + +What is partially or simplistically implemented +Not implemented (or not fully implemented) +What is not yet implemented (or only partially implemented): + +Icon placement and icon support +In MapLibre, labels can be not only text but also icons (icon-image, icon-size, etc.). + +Layout Expressions +In MapLibre, layout expressions can be very complex (e.g., depending on zoom, feature-state, data-driven styling). + +Collision Groups +MapLibre allows specifying collision groups (collision-group) so that labels from different groups do not interfere with each other. + +Rotated collision box +In MapLibre, rotated collision boxes are used for text along lines, so collisions are calculated based on the actual position of the text, not just AABB. + +Complex scenarios with multilingual labels, fallback, bidirectional text +MapLibre supports advanced language scenarios. + +Dynamic loading and removal of labels when zoom/viewport changes +In MapLibre, placement can be global for the entire frame, not just per tile. + +Everything related to basic rendering, collisions, and text placement is implemented close to MapLibre. +To be improved: rotated collision box, spatial index, icon support, complex expressions, collision groups. + + + + diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/compose/rememberRasterizer.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/compose/rememberRasterizer.kt new file mode 100644 index 00000000..161b3765 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/compose/rememberRasterizer.kt @@ -0,0 +1,38 @@ +package ovh.plrapps.mapcompose.vector.compose + +//import androidx.compose.runtime.Composable +//import androidx.compose.runtime.getValue +//import androidx.compose.runtime.produceState +//import androidx.compose.runtime.remember +//import androidx.compose.ui.platform.LocalDensity +//import androidx.compose.ui.platform.LocalFontFamilyResolver +//import androidx.compose.ui.text.rememberTextMeasurer +//import org.jetbrains.compose.resources.ExperimentalResourceApi +//import ovh.plrapps.mapcompose.vector.data.MapLibreConfiguration +//import ovh.plrapps.mapcompose.vector.data.getMapLibreConfiguration +//import kotlin.math.roundToInt + +//@OptIn(ExperimentalResourceApi::class) +//@Composable +//fun rememberRasterizer(styleSource: suspend () -> String): MapLibreRasterizer? { +// val density = LocalDensity.current +// val fontFamilyResolver = LocalFontFamilyResolver.current +// val textMeasurer = rememberTextMeasurer() +// val configuration by produceState(null) { +// value = styleSource().let { +// getMapLibreConfiguration(it, pixelRatio = density.density.roundToInt()).getOrThrow() +// } +// } +// +// return remember(configuration) { +// configuration?.let { +// MapLibreRasterizer( +// configuration = it, +// density = density, +// fontFamilyResolver = fontFamilyResolver, +// textMeasurer = textMeasurer, +// tileCache = null +// ) +// } +// } +//} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/core/VectorLayer.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/core/VectorLayer.kt new file mode 100644 index 00000000..7cdd420b --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/core/VectorLayer.kt @@ -0,0 +1,179 @@ +package ovh.plrapps.mapcompose.vector.core + +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.io.Buffer +import kotlinx.io.buffered +import kotlinx.io.readString +import ovh.plrapps.mapcompose.api.centroidX +import ovh.plrapps.mapcompose.api.centroidY +import ovh.plrapps.mapcompose.api.fullSize +import ovh.plrapps.mapcompose.api.scale +import ovh.plrapps.mapcompose.core.TileMatrix +import ovh.plrapps.mapcompose.core.TileStreamProvider +import ovh.plrapps.mapcompose.vector.core.VectorTileStreamProvider +import ovh.plrapps.mapcompose.core.Viewport +import ovh.plrapps.mapcompose.core.VisibleTiles +import ovh.plrapps.mapcompose.core.VisibleWindow +import ovh.plrapps.mapcompose.ui.state.MapState +import ovh.plrapps.mapcompose.utils.IODispatcher +import ovh.plrapps.mapcompose.utils.throttle +import ovh.plrapps.mapcompose.vector.data.extension.toBytes +import ovh.plrapps.mapcompose.vector.data.extension.toMVTViewport +import ovh.plrapps.mapcompose.vector.data.getMapLibreConfiguration + +internal class VectorLayer( + private val mapState: MapState, + private val vectorTileStreamProvider: VectorTileStreamProvider, +) { + private val scope = mapState.scope + + val visibleTiles = + mapState.tileCanvasState.visibleTiles.stateIn(scope, SharingStarted.Eagerly, null) + val viewportInfoFlow = MutableStateFlow(null) + + init { + listenForViewportUpdates() + } + + suspend fun makeTileStreamProvider(): TileStreamProvider { + val style = withContext(IODispatcher) { + vectorTileStreamProvider.loadResources(vectorTileStreamProvider.styleUrl)?.buffered()?.readString() + } + + val configuration = getMapLibreConfiguration( + style = style ?: "", + loadResource = vectorTileStreamProvider::loadResources + ).getOrThrow() + + val rasterizer = VectorRasterizer( + configuration = configuration, + densityState = mapState.densityState, + fontFamilyResolverState = mapState.fontFamilyResolverState, + textMeasurerState = mapState.textMeasurerState, + getTileStream = vectorTileStreamProvider::getTileStream + ) + + startSymbolsProcessing(rasterizer) + + return TileStreamProvider { row, col, zoomLvl -> + val density = mapState.densityState.value ?: return@TileStreamProvider null + val tilePx = with(density) { 256.dp.toPx() }.toInt() + + val imageBitmap = rasterizer.getTile( + x = col, + y = row, + zoom = zoomLvl.toDouble(), + tileSize = tilePx + ) + + val bytes = imageBitmap.toBytes() + ?: return@TileStreamProvider null + + Buffer().apply { + write(bytes) + } + } + } + + private fun startSymbolsProcessing(rasterizer: VectorRasterizer) { + scope.launch { + viewportInfoFlow + .throttle(250) + .collectLatest { viewportInfo -> + viewportInfo ?: return@collectLatest + + val zoomLvl = viewportInfo.zoom + val tilePx = mapState.tileSize + + val nextSymbols = rasterizer.produceSymbols( + viewport = viewportInfo.toMVTViewport(), + tileSize = tilePx, + z = zoomLvl.toDouble() + ).getOrElse { e -> + println("[ERROR] produceSymbols(): ${e.message}") + return@collectLatest + } + + rasterizer.updateSymbols( + nextSymbols = nextSymbols, + state = mapState, + viewportInfo = viewportInfo + ) + mapState.symbolState.visiblePhases = viewportInfo.visiblePhases + } + } + } + + private fun listenForViewportUpdates() { + var lastViewport: Viewport? = null + fun updateViewportInfo(visibleTiles: VisibleTiles, viewport: Viewport) { + val visibleWindow = visibleTiles.visibleWindow + val (mergedMatrix, leftVisible, rightVisible) = when (visibleWindow) { + is VisibleWindow.InfiniteScrollX -> Triple( + mergeMatrices( + visibleWindow.tileMatrix, + visibleWindow.leftOverflow?.tileMatrix, + visibleWindow.rightOverflow?.tileMatrix + ), + visibleWindow.leftOverflow != null, + visibleWindow.rightOverflow != null + ) + is VisibleWindow.BoundsConstrained -> Triple(visibleWindow.tileMatrix, false, false) + } + viewportInfoFlow.value = ViewportInfo( + matrix = mergedMatrix, + size = IntSize( + width = (viewport.right - viewport.left), + height = (viewport.bottom - viewport.top), + ), + angleRad = viewport.angleRad, + pitch = 0f, + zoom = visibleTiles.level, + centroidX = mapState.centroidX, + centroidY = mapState.centroidY, + scale = mapState.scale, + fullWidth = mapState.fullSize.width, + fullHeight = mapState.fullSize.height, + infiniteScrollX = leftVisible || rightVisible, + visiblePhases = (if (leftVisible) -1 else 0)..(if (rightVisible) 1 else 0), + ) + } + + mapState.addViewportChangeListener { viewport -> + val currentVisibleTiles = visibleTiles.value + if (currentVisibleTiles != null) { + lastViewport = viewport + updateViewportInfo(currentVisibleTiles, viewport) + } + } + + scope.launch { + visibleTiles.collect { visibleTiles -> + visibleTiles ?: return@collect + val viewport = lastViewport ?: return@collect + updateViewportInfo(visibleTiles, viewport) + } + } + } + + private fun mergeMatrices(vararg matrices: TileMatrix?): TileMatrix { + val result = mutableMapOf() + for (matrix in matrices) { + matrix ?: continue + for ((row, cols) in matrix) { + val existing = result[row] + result[row] = if (existing == null) cols else { + minOf(existing.first, cols.first)..maxOf(existing.last, cols.last) + } + } + } + return result + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/core/VectorRasterizer.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/core/VectorRasterizer.kt new file mode 100644 index 00000000..1f76ec69 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/core/VectorRasterizer.kt @@ -0,0 +1,720 @@ +package ovh.plrapps.mapcompose.vector.core + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.io.RawSource +import kotlinx.io.buffered +import kotlinx.io.readByteArray +import ovh.plrapps.mapcompose.core.TileMatrix +import ovh.plrapps.mapcompose.ui.state.MapState +import ovh.plrapps.mapcompose.utils.AngleRad +import ovh.plrapps.mapcompose.utils.IODispatcher +import ovh.plrapps.mapcompose.vector.data.MapLibreConfiguration +import ovh.plrapps.mapcompose.vector.renderer.CompoundLabelPlacement +import ovh.plrapps.mapcompose.vector.renderer.Point +import ovh.plrapps.mapcompose.vector.renderer.Symbol +import ovh.plrapps.mapcompose.vector.renderer.SymbolsProducer +import ovh.plrapps.mapcompose.vector.renderer.TextPlacementCandidate +import ovh.plrapps.mapcompose.vector.renderer.TileRenderer +import ovh.plrapps.mapcompose.vector.renderer.collision.CollisionDetector +import ovh.plrapps.mapcompose.vector.renderer.collision.LabelPlacement +import ovh.plrapps.mapcompose.vector.renderer.utils.MVTViewport +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.utils.LruCache +import ovh.plrapps.mapcompose.vector.spec.style.SymbolLayer +import ovh.plrapps.mapcompose.vector.utils.obb.OBB +import ovh.plrapps.mapcompose.vector.utils.obb.ObbPoint +import pbandk.decodeFromByteArray +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +class VectorRasterizer( + val configuration: MapLibreConfiguration, + val densityState: MutableStateFlow, + val fontFamilyResolverState: MutableStateFlow, + val textMeasurerState: MutableStateFlow, + val getTileStream: suspend (url: String, row: Int, col: Int, zoomLvl: Int) -> RawSource?, + val tileSize: Int = 256, +) { + // Decoded protobuf Tile objects are large (up to several MB each in dense areas). + // Keep size modest; raw bytes remain available in byteCache for cheap re-decoding. + private val tileCache = LruCache(maxSize = 30) + private val byteCache = LruCache(maxSize = 100) + // Rendered tile output (PNG bytes). Cache hit avoids full geometry re-render. + private val renderedByteCache = LruCache(maxSize = 50) + private val pathCache = LruCache(maxSize = 200) + // Separate mutexes per cache eliminate cross-cache contention when tiles render concurrently. + private val byteCacheMutex = Mutex() + private val tileCacheMutex = Mutex() + private val pathCacheMutex = Mutex() + private val renderedByteCacheMutex = Mutex() + + // Precomputed set of all source names referenced by style layers (constant after init). + private val allSourceNames: Set by lazy { + configuration.style.layers.mapNotNull { it.source?.takeIf { s -> s.isNotBlank() } }.toSet() + } + + // Minimum viewport-pixel distance between repetitions of the same line label across tiles. + // Mirrors MapLibre's default symbol-spacing (250px). + private val MIN_LINE_LABEL_REPEAT_DIST = 250f + + private val stableAnchorCache = LruCache(maxSize = 1000) + + private fun getTileKey(sourceName: String, z: Int, x: Int, y: Int): String { + return "$sourceName-$z-$x-$y" + } + + fun decodePBFFromByteArray(bytes: ByteArray): Tile? { + return try { + Tile.decodeFromByteArray(bytes) + } catch (e: Exception) { + println("Error decoding PBF: ${e.message}") + null + } + } + + private suspend fun renderTile( + pbfList: Map, + zoom: Double, + tileSize: Int, + actualZoom: Double, + x: Int, + y: Int, + ): ImageBitmap { + val z = zoom.toInt() + val density = densityState.value ?: return emptyBitmap(tileSize) + + // One tileCache lookup per source (not per style layer) — reduces mutex ops from + // O(style_layers) to O(sources). + val tileForSource: Map = allSourceNames.associateWith { sourceName -> + val key = getTileKey(sourceName, z, x, y) + tileCacheMutex.withLock { tileCache.get(key) } + ?: pbfList[sourceName]?.let { bytes -> + decodePBFFromByteArray(bytes)?.also { t -> + tileCacheMutex.withLock { tileCache.put(key, t) } + } + } + } + + val imageBitmap = ImageBitmap(tileSize, tileSize) + val canvas = Canvas(imageBitmap) + val drawScope = CanvasDrawScope() + val localPropCache = HashMap() + val tileRenderer = TileRenderer( + configuration = configuration, + pathCache = pathCache, + pathCacheMutex = pathCacheMutex, + localPropCache = localPropCache + ) + + drawScope.draw( + density = density, + layoutDirection = LayoutDirection.Ltr, + canvas = canvas, + size = Size(tileSize.toFloat(), tileSize.toFloat()) + ) { + for (styleLayer in configuration.style.layers) { + val sourceName = styleLayer.source.takeIf { !it.isNullOrBlank() } + val tileKey = sourceName?.let { getTileKey(it, z, x, y) } + val tile = sourceName?.let { tileForSource[it] } + + tileRenderer.render( + canvas = this, + tile = tile, + styleLayer = styleLayer, + zoom = zoom, + canvasSize = tileSize, + actualZoom = actualZoom, + tileKey = tileKey + ) + } + } + return imageBitmap + } + + private suspend fun fetchTile(url: String, row: Int, col: Int, zoomLvl: Int, sourceName: String): Result { + val key = getTileKey(sourceName, zoomLvl, col, row) + byteCacheMutex.withLock { + byteCache.get(key)?.let { return Result.success(it) } + } + + try { + // Check if the coroutine is cancelled before the network request + currentCoroutineContext().ensureActive() + +// println("fetch the tile $url") + val result = withContext(IODispatcher) { + val response = getTileStream(url, row, col, zoomLvl) + response?.buffered()?.use { bufferedSource -> + bufferedSource.readByteArray() + } + } ?: return Result.failure(LoadTileException("fetch error")) + byteCacheMutex.withLock { + byteCache.put(key, result) + } + return Result.success(result) + } catch (e: CancellationException) { + // We do not log cancellation as an error - this is normal behavior + throw e + } catch (e: Throwable) { + return Result.failure(e) + } + } + + // return sourceName: String and tile: ByteArray + private suspend fun fetch(z: Int, x: Int, y: Int): Result> = supervisorScope { + try { + // Kick off concurrent fetches per source without failing the whole scope on one error + val deferred = configuration.tileSources.map { (sourceName, ts) -> + sourceName to async { + // Ensure still active before heavy work + coroutineContext.ensureActive() + fetchTile(ts.getTileUrl(z = z, x = x, y = y), row = y, col = x, zoomLvl = z, sourceName = sourceName).getOrThrow() + } + } + + // Await all; collect successes, ignore failures so partial data can still render + val buffer = mutableMapOf() + for ((sourceName, d) in deferred) { + runCatching { d.await() } + .onSuccess { bytes -> buffer[sourceName] = bytes } + .onFailure { /* ignore single source failure */ } + } + + return@supervisorScope Result.success(buffer) + } catch (t: Throwable) { + Result.failure(t) + } + } + + private fun emptyBitmap(size: Int): ImageBitmap { + val density = densityState.value ?: return ImageBitmap(size, size) + + val imageBitmap = ImageBitmap(size, size) + val canvas = Canvas(imageBitmap) + val drawScope = CanvasDrawScope() + drawScope.draw( + density = density, + layoutDirection = LayoutDirection.Ltr, + canvas = canvas, + size = Size(size.toFloat(), size.toFloat()) + ) { + this.drawRect( + color = Color.LightGray, + ) + } + return imageBitmap + } + + suspend fun getTile( + x: Int, + y: Int, + zoom: Double, + tileSize: Int, + ): ImageBitmap { + val z = zoom.toInt() + val pbfList = fetch(z = z, x = x, y = y).getOrElse { e -> + println("ERROR: ${e.message}") + return emptyBitmap(tileSize) + } + + return renderTile( + pbfList = pbfList, + zoom = zoom, + tileSize = tileSize, + actualZoom = zoom, + x = x, + y = y, + ) + } + + private val symbolsProducer = SymbolsProducer( + textMeasurer = textMeasurerState, + configuration = configuration, + pathCache = pathCache, + pathCacheMutex = pathCacheMutex + ) + + suspend fun produceSymbols(viewport: MVTViewport, tileSize: Int, z: Double): Result> = withContext( + Dispatchers.Default + ) { + val density = densityState.value ?: return@withContext Result.failure(LoadTileException("density is null")) + + val symbols = mutableListOf() + if (viewport.tileMatrix.isEmpty()) return@withContext Result.success(symbols) + + val maxTileIndex = (1 shl viewport.zoom.toInt()) - 1 + val visibleRowMin = viewport.tileMatrix.keys.min() + val visibleRowMax = viewport.tileMatrix.keys.max() + + // Expand by 1 tile in each direction so edge symbols are collision-checked + // against off-screen content (MapLibre-style viewport padding). + val expandedTiles = mutableMapOf() + for ((row, cols) in viewport.tileMatrix) { + if (cols.isEmpty()) continue + expandedTiles[row] = (cols.min() - 1).coerceAtLeast(0)..(cols.max() + 1).coerceAtMost(maxTileIndex) + } + for (adjRow in listOf(visibleRowMin - 1, visibleRowMax + 1)) { + if (adjRow < 0 || adjRow > maxTileIndex) continue + val refRow = if (adjRow < visibleRowMin) visibleRowMin else visibleRowMax + val refCols = viewport.tileMatrix[refRow] ?: continue + if (refCols.isEmpty()) continue + expandedTiles[adjRow] = (refCols.min() - 1).coerceAtLeast(0)..(refCols.max() + 1).coerceAtMost(maxTileIndex) + } + + for ((y, colRange) in expandedTiles) { + for (x in colRange) { + // Loading PBF for the tile + val pbfList = fetch(z = z.toInt(), x = x, y = y).getOrElse { e -> + println("ERROR: ${e.message}") + return@withContext Result.failure(e) + } + + // One tileCache lookup per source (not per style layer). + val tileForSource: Map = allSourceNames.associateWith { sourceName -> + val key = getTileKey(sourceName, z.toInt(), x, y) + tileCacheMutex.withLock { tileCache.get(key) } + ?: pbfList[sourceName]?.let { bytes -> + decodePBFFromByteArray(bytes)?.also { t -> + tileCacheMutex.withLock { tileCache.put(key, t) } + } + } + } + + val localPropCache = HashMap() + + // Use withIndex() to avoid O(n²) indexOf in the loop. + for ((layerIndex, styleLayer) in configuration.style.layers.withIndex()) { + if (styleLayer !is SymbolLayer) continue + val tile = styleLayer.source.takeIf { !it.isNullOrBlank() } + ?.let { tileForSource[it] } ?: continue + + symbolsProducer.produce( + tile = tile, + styleLayer = styleLayer, + layerIndex = layerIndex, + zoom = z, + canvasSize = tileSize, + actualZoom = z, + tileX = x, + tileY = y, + density = density, + localPropCache = localPropCache + ).let { + symbols.addAll(it) + } + } + } + } + + Result.success(symbols) + } + + fun updateSymbols(nextSymbols: List, state: MapState, viewportInfo: ViewportInfo) { + // run collision detection if enabled + val symbols = when (this.configuration.collisionDetectionEnabled) { + true -> clearCollision(nextSymbols, viewportInfo) + else -> nextSymbols + } + + checkIndexErrors(symbols) + println("xxxxx number of symbols ${symbols.size}") + state.symbolState.symbols = symbols + } + + private class Counter { + var value: Int = 0 + } + + private fun checkIndexErrors(nextSymbols: List) { + val counters = mutableMapOf() + + nextSymbols.forEach { symbol -> + counters.getOrPut(symbol.id) { Counter() }.value++ + } + counters.forEach { (id, value) -> + if (value.value > 1) { + println("xxxxx [ERROR]: Id $id not unique (${value.value})") + } + } + } + + /** + * Creates a LabelPlacement with viewport coordinates. + * + * [mapRotationDeg] is added to the OBB rotation only for symbols that rotate with the map + * (i.e. line text whose own angle is non-zero). Point symbols keep angle = 0 regardless of + * map rotation because their rotation-alignment defaults to "viewport". + */ + private fun createViewportLabelPlacement( + center: Offset, + originalPlacement: LabelPlacement, + mapRotationDeg: Float = 0f + ): LabelPlacement { + val bounds = originalPlacement.bounds + val ownAngle = originalPlacement.angle + + // The dimensions remain the same + val rectWidth = bounds.width + val rectHeight = bounds.height + + // New bounds relative to viewport center + val newBounds = Rect( + left = center.x - rectWidth / 2f, + top = center.y - rectHeight / 2f, + right = center.x + rectWidth / 2f, + bottom = center.y + rectHeight / 2f + ) + + // Line text (non-zero angle) rotates with the map; point symbols stay viewport-aligned. + val effectiveAngle = if (ownAngle != 0f) ownAngle + mapRotationDeg else ownAngle + + return originalPlacement.copy( + position = ObbPoint(center.x, center.y), + bounds = newBounds, + obb = OBB( + center = ObbPoint(center.x, center.y), + size = ovh.plrapps.mapcompose.vector.utils.obb.Size(rectWidth, rectHeight), + rotation = effectiveAngle + ) + ) + } + + /** + * Converts normalized Mercator coordinates to viewport (screen) pixel coordinates, + * accounting for the current map scale and rotation. + */ + private fun mercatorToViewport( + mercatorX: Double, + mercatorY: Double, + viewportInfo: ViewportInfo + ): Offset { + val centroidX = viewportInfo.centroidX + val centroidY = viewportInfo.centroidY + val currentScale = viewportInfo.scale + + var deltaX = mercatorX - centroidX + if (viewportInfo.infiniteScrollX) { + if (deltaX > 0.5) deltaX -= 1.0 + else if (deltaX < -0.5) deltaX += 1.0 + } + val deltaY = mercatorY - centroidY + + val viewportCenterX = viewportInfo.size.width.toDouble() / 2.0 + val viewportCenterY = viewportInfo.size.height.toDouble() / 2.0 + + // Scale the map-space delta to screen pixels + val scaledDX = deltaX * viewportInfo.fullWidth.toDouble() * currentScale + val scaledDY = deltaY * viewportInfo.fullHeight.toDouble() * currentScale + + // Compose rotate(θ) is clockwise, matching x'=dx*cos(θ)-dy*sin(θ); use +angleRad. + val angle = viewportInfo.angleRad.toDouble() + val viewportX = viewportCenterX + scaledDX * cos(angle) - scaledDY * sin(angle) + val viewportY = viewportCenterY + scaledDX * sin(angle) + scaledDY * cos(angle) + + return Offset(viewportX.toFloat(), viewportY.toFloat()) + } + + /** + * Sort symbols according to priority + */ + private fun sortForPlacement(symbols: List): List { + return symbols.withIndex() + .sortedWith( + compareByDescending> { + it.value.placement.spritePlacement.layerIndex + }.thenBy { + it.value.placement.spritePlacement.inLayerPriority + }.thenBy { + it.index + } + ) + .map { it.value } + } + + private fun makeStableAnchorKey(symbol: Symbol.SpriteWithText): String { + val text = symbol.textCandidates.firstOrNull()?.labelPlacement?.text ?: "" + val gx = (symbol.global.x * 100_000.0).toLong() + val gy = (symbol.global.y * 100_000.0).toLong() + return "${text}_${gx}_${gy}" + } + + /** + * Detects collisions and determines whether the element can be placed or not. + */ + private fun clearCollision(symbols: List, viewportInfo: ViewportInfo): List { + val collisionDetector = CollisionDetector() + val sortedSymbols = sortForPlacement(symbols) + val acceptedSymbols = mutableListOf() + val mapRotationDeg = viewportInfo.angleRad * (180f / kotlin.math.PI.toFloat()) + // Cross-tile line-label deduplication: track placed viewport positions per text string. + val placedLineTextPositions = mutableMapOf>() + + sortedSymbols.forEach { symbol -> + val viewportPos = mercatorToViewport( + mercatorX = symbol.global.x, + mercatorY = symbol.global.y, + viewportInfo = viewportInfo + ) + + val spritePlacement = symbol.placement.spritePlacement + val textPlacement = symbol.placement.textPlacement + + when (symbol) { + is Symbol.SpriteWithText -> { + val spriteViewportPlacement = createViewportLabelPlacement( + center = viewportPos, + originalPlacement = spritePlacement, + mapRotationDeg = mapRotationDeg + ) + + if (symbol.textCandidates.isNotEmpty()) { + // Candidate path: try each text position in order, pick the first that fits. + // Used for both text-variable-anchor (multiple candidates) and text-anchor + // (single candidate) — textCandidates is always non-empty for SpriteWithText. + if (!collisionDetector.wouldCollide(spriteViewportPlacement)) { + var placed = false + val stableKey = makeStableAnchorKey(symbol) + val lastIndex = stableAnchorCache.get(stableKey) + val orderedCandidates: List> = + if (lastIndex != null && lastIndex < symbol.textCandidates.size) { + listOf(IndexedValue(lastIndex, symbol.textCandidates[lastIndex])) + + symbol.textCandidates.withIndex().filter { it.index != lastIndex } + } else { + symbol.textCandidates.withIndex().toList() + } + for ((index, candidate) in orderedCandidates) { + val textVP = mercatorToViewport(candidate.mercatorX, candidate.mercatorY, viewportInfo) + val textVPPlacement = createViewportLabelPlacement(textVP, candidate.labelPlacement, mapRotationDeg) + if (!collisionDetector.wouldCollide(textVPPlacement)) { + collisionDetector.insert(spriteViewportPlacement) + collisionDetector.insert(textVPPlacement) + stableAnchorCache.put(stableKey, index) + // Text added first: SymbolComposer draws reversed(), so first + // entries are drawn on top — text should render above sprite. + acceptedSymbols.add( + Symbol.Text( + id = "${symbol.id}_t", + global = Point(candidate.mercatorX, candidate.mercatorY), + placement = CompoundLabelPlacement(candidate.labelPlacement, candidate.labelPlacement), + value = symbol.text, + spriteAnchorGlobal = symbol.global, + textOffset = Pair(candidate.dx, candidate.dy), + ) + ) + acceptedSymbols.add( + Symbol.Sprite( + id = "${symbol.id}_s", + global = symbol.global, + placement = CompoundLabelPlacement(spritePlacement, null), + value = symbol.sprite, + ) + ) + placed = true + break + } + } + if (!placed && symbol.textOptional) { + collisionDetector.insert(spriteViewportPlacement) + acceptedSymbols.add( + Symbol.Sprite( + id = symbol.id, + global = symbol.global, + placement = CompoundLabelPlacement(spritePlacement, null), + value = symbol.sprite, + ) + ) + } + } else if (symbol.iconOptional) { + // Sprite collides but icon is optional — try text-only placement. + for (candidate in symbol.textCandidates) { + val textVP = mercatorToViewport(candidate.mercatorX, candidate.mercatorY, viewportInfo) + val textVPPlacement = createViewportLabelPlacement(textVP, candidate.labelPlacement, mapRotationDeg) + if (!collisionDetector.wouldCollide(textVPPlacement)) { + collisionDetector.insert(textVPPlacement) + acceptedSymbols.add( + Symbol.Text( + id = "${symbol.id}_t", + global = Point(candidate.mercatorX, candidate.mercatorY), + placement = CompoundLabelPlacement(candidate.labelPlacement, candidate.labelPlacement), + value = symbol.text, + ) + ) + break + } + } + } + } else { + val textViewportPlacement = textPlacement?.let { textPlace -> + val spriteHeight = symbol.spriteSize.height.toFloat() + val verticalGap = symbol.verticalGap + val textHeight = symbol.textSize.height.toFloat() + + val textOffsetY = spriteHeight / 2f + verticalGap + textHeight / 2f + // SpriteWithText is viewport-aligned: text is always directly below the + // sprite in screen space, regardless of map rotation. + val textViewportPos = Offset(viewportPos.x, viewportPos.y + textOffsetY) + + createViewportLabelPlacement( + center = textViewportPos, + originalPlacement = textPlace, + mapRotationDeg = mapRotationDeg + ) + } + + val spriteCanPlace = !collisionDetector.wouldCollide(spriteViewportPlacement) + val textCanPlace = textViewportPlacement?.let { + !collisionDetector.wouldCollide(it) + } ?: true + + if (spriteCanPlace && textCanPlace) { + // Use insert() (not tryPlaceLabel) because we already validated both parts + // above. tryPlaceLabel would re-check after sprite is inserted and reject + // the text OBB since padded sprite/text boxes always overlap each other. + collisionDetector.insert(spriteViewportPlacement) + textViewportPlacement?.let { collisionDetector.insert(it) } + acceptedSymbols.add(symbol) + } else if (spriteCanPlace && !textCanPlace && symbol.textOptional) { + collisionDetector.insert(spriteViewportPlacement) + acceptedSymbols.add( + Symbol.Sprite( + id = symbol.id, + global = symbol.global, + placement = CompoundLabelPlacement(spritePlacement, spritePlacement), + value = symbol.sprite + ) + ) + } else if (!spriteCanPlace && textCanPlace && symbol.iconOptional && textPlacement != null && textViewportPlacement != null) { + collisionDetector.insert(textViewportPlacement) + acceptedSymbols.add( + Symbol.Text( + id = symbol.id, + global = symbol.global, + placement = CompoundLabelPlacement(textPlacement, textPlacement), + value = symbol.text + ) + ) + } + } + } + + is Symbol.Sprite -> { + val spriteViewportPlacement = createViewportLabelPlacement( + center = viewportPos, + originalPlacement = spritePlacement, + mapRotationDeg = mapRotationDeg + ) + + val textViewportPlacement = textPlacement?.let { textPlace -> + createViewportLabelPlacement( + center = viewportPos, + originalPlacement = textPlace, + mapRotationDeg = mapRotationDeg + ) + } + + val spriteCanPlace = !collisionDetector.wouldCollide(spriteViewportPlacement) + val textCanPlace = textViewportPlacement?.let { + !collisionDetector.wouldCollide(it) + } ?: true + + if (spriteCanPlace && textCanPlace) { + collisionDetector.insert(spriteViewportPlacement) + textViewportPlacement?.let { collisionDetector.insert(it) } + acceptedSymbols.add(symbol) + } + } + + is Symbol.Text -> { + // textPlacement is always non-null for Symbol.Text (set in producePointText/produceLineText) + val resolvedTextPlacement = textPlacement ?: return@forEach + val textViewportPlacement = createViewportLabelPlacement( + center = viewportPos, + originalPlacement = resolvedTextPlacement, + mapRotationDeg = mapRotationDeg + ) + + // Cross-tile deduplication for line labels: suppress if a same-text label was + // already placed within MIN_LINE_LABEL_REPEAT_DIST viewport pixels. Point labels + // (viewportAligned = true) rely on collision detection instead. + if (!symbol.viewportAligned) { + val text = resolvedTextPlacement.text + val prev = placedLineTextPositions[text] + if (prev != null && prev.any { placed -> + val dx = viewportPos.x - placed.x + val dy = viewportPos.y - placed.y + sqrt(dx * dx + dy * dy) < MIN_LINE_LABEL_REPEAT_DIST + }) { + return@forEach + } + } + + if (!collisionDetector.wouldCollide(textViewportPlacement)) { + collisionDetector.insert(textViewportPlacement) + acceptedSymbols.add(symbol) + if (!symbol.viewportAligned) { + placedLineTextPositions + .getOrPut(resolvedTextPlacement.text) { mutableListOf() } + .add(viewportPos) + } + } + } + } + } + + return acceptedSymbols + } +} + +/** + * Provides information about the current state of the viewport in a MapLibre-like map renderer. + * + * @property matrix The current tile transformation matrix, describing how map tiles are projected and positioned in the viewport. + * @property size The pixel size (width and height) of the viewport. + * @property angleRad The rotation angle of the viewport, in radians. + * @property pitch The pitch (tilt) of the viewport, in degrees (0 = looking straight down). + * @property zoom The current zoom level, providing continuous zoom information. + */ +data class ViewportInfo( + val matrix: TileMatrix, + val size: IntSize, + val angleRad: AngleRad, + val pitch: Float, + val zoom: Int, + + // Snapshot values + val centroidX: Double, + val centroidY: Double, + val scale: Double, + val fullWidth: Int, + val fullHeight: Int, + val infiniteScrollX: Boolean = false, + val visiblePhases: IntRange = 0..0, +) + +class LoadTileException(msg: String) : Exception(msg) \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/core/VectorTileStreamProvider.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/core/VectorTileStreamProvider.kt new file mode 100644 index 00000000..102f4375 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/core/VectorTileStreamProvider.kt @@ -0,0 +1,41 @@ +package ovh.plrapps.mapcompose.vector.core + +import kotlinx.io.RawSource + +/** + * Defines how vector tiles should be fetched. It must be supplied as part of the configuration of + * MapCompose. + * + * The [styleUrl] property is used to identify the style of the vector tiles. + * + * The [loadResources] method is used to load resources (e.g, stylesheets) required to render the vector + * tiles. + * + * The [getTileStream] method implementation may suspend, but it isn't required (e.g, it isn't + * required to switch context using withContext(Dispatcher.IO) { .. }) as MapCompose does that + * already. The [getTileStream] method is declared using the suspend modifier, as it is sometimes + * useful to provide an implementation which suspends. + * + * The [getTileStream] method is invoked with the following parameters: + * - [tileUrl]: the url of the tile to fetch. + * - [row]: the row index of the tile. + * - [col]: the column index of the tile. + * - [zoomLvl]: the zoom level of the tile. + * [row], [col] and [zoomLvl] are can be used to implement caching strategies, because the [tileUrl] may change + * when multiple servers are used. + * + * MapCompose leverages bitmap pooling to reduce the pressure on the garbage collector. However, + * there's no tile caching by default - this is an implementation detail of the supplied + * [VectorTileStreamProvider]. + * + * If [getTileStream] returns null, the tile won't be rendered. + * The library does not handle exceptions thrown from [getTileStream]. Such errors are treated as + * unrecoverable failures. + */ +interface VectorTileStreamProvider { + val styleUrl: String + + suspend fun loadResources(url: String): RawSource? + + suspend fun getTileStream(tileUrl: String, row: Int, col: Int, zoomLvl: Int): RawSource? +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/JsonConfig.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/JsonConfig.kt new file mode 100644 index 00000000..c67f640f --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/JsonConfig.kt @@ -0,0 +1,17 @@ +package ovh.plrapps.mapcompose.vector.data + +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.contextual +import ovh.plrapps.mapcompose.vector.spec.style.serializers.ColorSerializer +import ovh.plrapps.mapcompose.vector.spec.style.symbol.TextAnchorSerializer + +val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + coerceInputValues = true + serializersModule = kotlinx.serialization.modules.SerializersModule { + contextual(ColorSerializer) + contextual(TextAnchorSerializer) + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/LanguageCode.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/LanguageCode.kt new file mode 100644 index 00000000..e348cd5b --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/LanguageCode.kt @@ -0,0 +1,172 @@ +package ovh.plrapps.mapcompose.vector.data + +enum class LanguageCode(val code: String, val title: String) { + Afar(code = "aa", title = "Afar"), + Abkhazian(code = "ab", title = "Abkhazian"), + Avestan(code = "ae", title = "Avestan"), + Afrikaans(code = "af", title = "Afrikaans"), + Akan(code = "ak", title = "Akan"), + Amharic(code = "am", title = "Amharic"), + Aragonese(code = "an", title = "Aragonese"), + Arabic(code = "ar", title = "Arabic"), + Assamese(code = "as", title = "Assamese"), + Avaric(code = "av", title = "Avaric"), + Aymara(code = "ay", title = "Aymara"), + Azerbaijani(code = "az", title = "Azerbaijani"), + Bashkir(code = "ba", title = "Bashkir"), + Belarusian(code = "be", title = "Belarusian"), + Bulgarian(code = "bg", title = "Bulgarian"), + Bislama(code = "bi", title = "Bislama"), + Bambara(code = "bm", title = "Bambara"), + Bengali(code = "bn", title = "Bengali"), + Tibetan(code = "bo", title = "Tibetan"), + Breton(code = "br", title = "Breton"), + Bosnian(code = "bs", title = "Bosnian"), + Catalan(code = "ca", title = "Catalan"), + Valencian(code = "ca", title = "Valencian"), + Chechen(code = "ce", title = "Chechen"), + Chamorro(code = "ch", title = "Chamorro"), + Corsican(code = "co", title = "Corsican"), + Cree(code = "cr", title = "Cree"), + Czech(code = "cs", title = "Czech"), + Chuvash(code = "cv", title = "Chuvash"), + Welsh(code = "cy", title = "Welsh"), + Danish(code = "da", title = "Danish"), + German(code = "de", title = "German"), + Divehi(code = "dv", title = "Divehi"), + Dzongkha(code = "dz", title = "Dzongkha"), + Ewe(code = "ee", title = "Ewe"), + English(code = "en", title = "English"), + Esperanto(code = "eo", title = "Esperanto"), + Spanish(code = "es", title = "Spanish"), + Estonian(code = "et", title = "Estonian"), + Basque(code = "eu", title = "Basque"), + Persian(code = "fa", title = "Persian"), + Fulah(code = "ff", title = "Fulah"), + Finnish(code = "fi", title = "Finnish"), + Fijian(code = "fj", title = "Fijian"), + Faroese(code = "fo", title = "Faroese"), + French(code = "fr", title = "French"), + WesternFrisian(code = "fy", title = "Western Frisian"), + Irish(code = "ga", title = "Irish"), + ScottishGaelic(code = "gd", title = "Gaelic; Scottish Gaelic"), + Galician(code = "gl", title = "Galician"), + Guarani(code = "gn", title = "Guarani"), + Gujarati(code = "gu", title = "Gujarati"), + Manx(code = "gv", title = "Manx"), + Hausa(code = "ha", title = "Hausa"), + Hebrew(code = "he", title = "Hebrew"), + Hindi(code = "hi", title = "Hindi"), + HiriMotu(code = "ho", title = "Hiri Motu"), + Croatian(code = "hr", title = "Croatian"), + Haitian(code = "ht", title = "Haitian; Haitian Creole"), + Hungarian(code = "hu", title = "Hungarian"), + Armenian(code = "hy", title = "Armenian"), + Herero(code = "hz", title = "Herero"), + Interlingua(code = "ia", title = "Interlingua (International Auxiliary Language Association)"), + Indonesian(code = "id", title = "Indonesian"), + Interlingue(code = "ie", title = "Interlingue; Occidental"), + Igbo(code = "ig", title = "Igbo"), + SichuanYi(code = "ii", title = "Sichuan Yi; Nuosu"), + Inupiaq(code = "ik", title = "Inupiaq"), + Ido(code = "io", title = "Ido"), + Icelandic(code = "is", title = "Icelandic"), + Italian(code = "it", title = "Italian"), + Inuktitut(code = "iu", title = "Inuktitut"), + Japanese(code = "ja", title = "Japanese"), + Javanese(code = "jv", title = "Javanese"), + Georgian(code = "ka", title = "Georgian"), + Kongo(code = "kg", title = "Kongo"), + Kikuyu(code = "ki", title = "Kikuyu; Gikuyu"), + Kuanyama(code = "kj", title = "Kuanyama; Kwanyama"), + Kazakh(code = "kk", title = "Kazakh"), + Kalaallisut(code = "kl", title = "Kalaallisut; Greenlandic"), + CentralKhmer(code = "km", title = "Central Khmer"), + Kannada(code = "kn", title = "Kannada"), + Korean(code = "ko", title = "Korean"), + Kanuri(code = "kr", title = "Kanuri"), + Kashmiri(code = "ks", title = "Kashmiri"), + Kurdish(code = "ku", title = "Kurdish"), + Komi(code = "kv", title = "Komi"), + Cornish(code = "kw", title = "Cornish"), + Kirghiz(code = "ky", title = "Kirghiz; Kyrgyz"), + Latin(code = "la", title = "Latin"), + Luxembourgish(code = "lb", title = "Luxembourgish; Letzeburgesch"), + Ganda(code = "lg", title = "Ganda"), + Limburgan(code = "li", title = "Limburgan; Limburger; Limburgish"), + Lingala(code = "ln", title = "Lingala"), + Lao(code = "lo", title = "Lao"), + Lithuanian(code = "lt", title = "Lithuanian"), + LubaKatanga(code = "lu", title = "Luba-Katanga"), + Latvian(code = "lv", title = "Latvian"), + Malagasy(code = "mg", title = "Malagasy"), + Marshallese(code = "mh", title = "Marshallese"), + Maori(code = "mi", title = "Maori"), + Macedonian(code = "mk", title = "Macedonian"), + Malayalam(code = "ml", title = "Malayalam"), + Mongolian(code = "mn", title = "Mongolian"), + Marathi(code = "mr", title = "Marathi"), + Malay(code = "ms", title = "Malay"), + Maltese(code = "mt", title = "Maltese"), + Burmese(code = "my", title = "Burmese"), + Nauru(code = "na", title = "Nauru"), + Nepali(code = "ne", title = "Nepali"), + Ndonga(code = "ng", title = "Ndonga"), + Dutch(code = "nl", title = "Dutch; Flemish"), + Norwegian(code = "no", title = "Norwegian"), + Ojibwa(code = "oj", title = "Ojibwa"), + Oromo(code = "om", title = "Oromo"), + Oriya(code = "or", title = "Oriya"), + Pali(code = "pi", title = "Pali"), + Polish(code = "pl", title = "Polish"), + Portuguese(code = "pt", title = "Portuguese"), + Quechua(code = "qu", title = "Quechua"), + Romansh(code = "rm", title = "Romansh"), + Rundi(code = "rn", title = "Rundi"), + Romanian(code = "ro", title = "Romanian; Moldavian; Moldovan"), + Russian(code = "ru", title = "Russian"), + Kinyarwanda(code = "rw", title = "Kinyarwanda"), + Sanskrit(code = "sa", title = "Sanskrit"), + Sardinian(code = "sc", title = "Sardinian"), + Sindhi(code = "sd", title = "Sindhi"), + Sango(code = "sg", title = "Sango"), + Slovak(code = "sk", title = "Slovak"), + Slovenian(code = "sl", title = "Slovenian"), + Samoan(code = "sm", title = "Samoan"), + Shona(code = "sn", title = "Shona"), + Somali(code = "so", title = "Somali"), + Albanian(code = "sq", title = "Albanian"), + Serbian(code = "sr", title = "Serbian"), + Swati(code = "ss", title = "Swati"), + Sundanese(code = "su", title = "Sundanese"), + Swedish(code = "sv", title = "Swedish"), + Swahili(code = "sw", title = "Swahili"), + Tamil(code = "ta", title = "Tamil"), + Telugu(code = "te", title = "Telugu"), + Tajik(code = "tg", title = "Tajik"), + Thai(code = "th", title = "Thai"), + Tigrinya(code = "ti", title = "Tigrinya"), + Turkmen(code = "tk", title = "Turkmen"), + Tagalog(code = "tl", title = "Tagalog"), + Tswana(code = "tn", title = "Tswana"), + Turkish(code = "tr", title = "Turkish"), + Tsonga(code = "ts", title = "Tsonga"), + Tatar(code = "tt", title = "Tatar"), + Twi(code = "tw", title = "Twi"), + Tahitian(code = "ty", title = "Tahitian"), + Ukrainian(code = "uk", title = "Ukrainian"), + Urdu(code = "ur", title = "Urdu"), + Uzbek(code = "uz", title = "Uzbek"), + Venda(code = "ve", title = "Venda"), + Vietnamese(code = "vi", title = "Vietnamese"), + Volapük(code = "vo", title = "Volapük"), + Walloon(code = "wa", title = "Walloon"), + Wolof(code = "wo", title = "Wolof"), + Xhosa(code = "xh", title = "Xhosa"), + Yiddish(code = "yi", title = "Yiddish"), + Yoruba(code = "yo", title = "Yoruba"), + Zhuang(code = "za", title = "Zhuang"), + Chuang(code = "za", title = "Chuang"), + Chinese(code = "zh", title = "Chinese"), + Zulu(code = "zu", title = "Zulu"); +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/MapLibreConfiguration.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/MapLibreConfiguration.kt new file mode 100644 index 00000000..3789e410 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/MapLibreConfiguration.kt @@ -0,0 +1,19 @@ +package ovh.plrapps.mapcompose.vector.data + +import ovh.plrapps.mapcompose.vector.spec.style.MapLibreStyle +import ovh.plrapps.mapcompose.vector.spec.style.utils.StyleDiagnostic + +data class MapLibreConfiguration( + val style: MapLibreStyle, + val tileSources: Map, + val spriteManager: SpriteManager?, + val collisionDetectionEnabled: Boolean = true, + val lang: LanguageCode? = LanguageCode.English, + /** + * Expression and filter problems found while parsing the style. + * + * Parsing is deliberately lenient — a property or filter that fails to compile is skipped + * rather than aborting the whole style — so this is where those failures surface. + */ + val diagnostics: List = emptyList(), +) diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/MapLibreTileSource.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/MapLibreTileSource.kt new file mode 100644 index 00000000..2f6d3b7f --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/MapLibreTileSource.kt @@ -0,0 +1,23 @@ +package ovh.plrapps.mapcompose.vector.data + +import ovh.plrapps.mapcompose.vector.spec.tilejson.TileJson + +class MapLibreTileSource(val tileJson: TileJson) { + + companion object { + const val SEGMENT_ZOOM = "{z}" + const val SEGMENT_X = "{x}" + const val SEGMENT_Y = "{y}" + } + + private val tileTemplates: List = tileJson.tiles + + fun getTileUrl(z: Int, x: Int, y: Int): String { + val template = tileTemplates.random() + + return template + .replace(SEGMENT_ZOOM, z.toString()) + .replace(SEGMENT_X, x.toString()) + .replace(SEGMENT_Y, y.toString()) + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.kt new file mode 100644 index 00000000..0b410602 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.kt @@ -0,0 +1,239 @@ +package ovh.plrapps.mapcompose.vector.data + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import kotlinx.coroutines.withContext +import kotlinx.io.RawSource +import kotlinx.io.buffered +import kotlinx.io.readByteArray +import kotlinx.io.readString +import org.jetbrains.compose.resources.ExperimentalResourceApi +import ovh.plrapps.mapcompose.utils.IODispatcher +import ovh.plrapps.mapcompose.vector.spec.sprites.Sprite +import ovh.plrapps.mapcompose.vector.utils.LruCache +import kotlin.math.max +import kotlin.math.roundToInt + +internal expect fun imageBitmapFromArgb( + argb: IntArray, + width: Int, + height: Int +): ImageBitmap + +internal expect fun byteArrayToImageBitmap(bytes: ByteArray): ImageBitmap + +class SpriteManager( + private val spriteIndex: Map, + private val spriteImage: ImageBitmap +) { + fun getSpriteInfo(spriteId: String): Sprite? { + val spriteInfo = spriteIndex[spriteId] ?: run { + return null + } + return spriteInfo + } + + /** + * Gets a sprite by its id + * @param spriteId Sprite id from JSON file + * @return Pair of sprite object with metadata and sprite cutout image + */ + private val spriteCache = LruCache(maxSize = 100) + + fun getSprite(spriteId: String, tintColor: Color? = null, sdf: SDF? = null): Pair? { + val spriteInfo = spriteIndex[spriteId] ?: return null + + val cacheKey = "$spriteId-${tintColor?.toArgb() ?: "none"}-${sdf?.hashCode() ?: "none"}" + spriteCache.get(cacheKey)?.let { + return spriteInfo to it + } + + var sprite = cropImageBitmap( + source = spriteImage, + x = spriteInfo.x, + y = spriteInfo.y, + width = spriteInfo.width, + height = spriteInfo.height, + tintColor = if (!spriteInfo.sdf && tintColor != null) tintColor else null + ) + + if (spriteInfo.sdf) { + sprite = renderSdf( + src = resizeImageBitmapWithAspectRatio( + src = sprite, + targetMaxSize = 128 // scale sdf for improve result + ), + sdf = sdf ?: error("sdf not provided") + ) + } + + spriteCache.put(cacheKey, sprite) + return spriteInfo to sprite + } + + fun getAvailableSprites(): List = spriteIndex.keys.toList() + + companion object { + var softness = 0.005f + + fun intArrayToImageByteArray(ints: IntArray): ByteArray { + val bytes = ByteArray(ints.size * 4) + for (i in ints.indices) { + val v = ints[i] + val j = i * 4 + bytes[j] = (v shr 0 and 0xFF).toByte() // B + bytes[j + 1] = (v shr 8 and 0xFF).toByte() // G + bytes[j + 2] = (v shr 16 and 0xFF).toByte() // R + bytes[j + 3] = (v shr 24 and 0xFF).toByte() // A + } + return bytes + } + + fun smoothstep(edge0: Float, edge1: Float, x: Float): Float { + val t = ((x - edge0) / (edge1 - edge0)).coerceIn(0f, 1f) + return t * t * (3 - 2 * t) + } + + fun resizeImageBitmapWithAspectRatio( + src: ImageBitmap, + targetMaxSize: Int, + filterQuality: FilterQuality = FilterQuality.High + ): ImageBitmap { + val srcWidth = src.width + val srcHeight = src.height + + val scale = targetMaxSize.toFloat() / max(srcWidth, srcHeight) + val dstWidth = (srcWidth * scale).roundToInt() + val dstHeight = (srcHeight * scale).roundToInt() + + val resizedBitmap = ImageBitmap(dstWidth, dstHeight) + + CanvasDrawScope().draw( + density = Density(1f), + layoutDirection = LayoutDirection.Ltr, + canvas = Canvas(resizedBitmap), + size = Size(dstWidth.toFloat(), dstHeight.toFloat()) + ) { + drawImage( + image = src, + dstSize = IntSize(dstWidth, dstHeight), + filterQuality = filterQuality + ) + } + return resizedBitmap + } + + fun renderSdf(src: ImageBitmap, sdf: SDF): ImageBitmap { + val width = src.width + val height = src.height + val strokeColor = sdf.haloColor + val strokeWidth = sdf.haloWidth + val fillColor = sdf.fillColor + + val srcPixels = src.toPixelMap() + + val fillEdge = sdf.threshold + val strokeEdge = fillEdge + strokeWidth + val outPixels = IntArray(width * height) + + for (y in 0 until height) { + for (x in 0 until width) { + val idx = y * width + x + val sdfVal = srcPixels[x, y].alpha + + val fillAlpha = smoothstep(fillEdge - softness, fillEdge + softness, sdfVal) + val strokeAlpha = smoothstep(strokeEdge - softness, strokeEdge + softness, sdfVal) + + val strokeOnly = (fillAlpha - strokeAlpha).coerceIn(0f, 1f) + val fillOnly = fillAlpha.coerceIn(0f, 1f) + + val r = fillColor.red * fillOnly + strokeColor.red * strokeOnly + val g = fillColor.green * fillOnly + strokeColor.green * strokeOnly + val b = fillColor.blue * fillOnly + strokeColor.blue * strokeOnly + val a = (fillOnly + strokeOnly).coerceIn(0f, 1f) + + outPixels[idx] = Color(r, g, b, a).toArgb() + } + } + + return imageBitmapFromArgb(outPixels, width, height) + } + + fun cropImageBitmap( + source: ImageBitmap, + x: Int, + y: Int, + width: Int, + height: Int, + tintColor: Color? = null, + ): ImageBitmap { + val result = ImageBitmap(width, height) + val canvas = Canvas(result) + canvas.drawImageRect( + image = source, + srcOffset = IntOffset(x, y), + srcSize = IntSize(width, height), + dstOffset = IntOffset(0, 0), + dstSize = IntSize(width, height), + paint = Paint().apply { + isAntiAlias = false + } + ) + + if (tintColor != null) { + canvas.drawRect( + Rect(0f, 0f, width.toFloat(), height.toFloat()), + Paint().apply { + color = tintColor + blendMode = BlendMode.SrcIn + } + ) + } + return result + } + + /** + * Loads sprites from the specified URL + * @param spriteUrl Sprite URL (without extension) + * @param pixelRatio Pixel density (1 or 2 for @2x) + * @return Result of loading sprites + */ + @OptIn(ExperimentalResourceApi::class) + suspend fun load(spriteUrl: String, pixelRatio: Int = 1, loadResource: suspend (String) -> RawSource?): Result { + val suffix = if (pixelRatio > 1) "@2x" else "" + val jsonUrl = "$spriteUrl$suffix.json" + val imageUrl = "$spriteUrl$suffix.png" + + return try { + val spriteJson = withContext(IODispatcher) { + loadResource(jsonUrl)?.buffered()?.readString() ?: throw Exception("Sprite JSON not found") + } + val spriteIndex = json.decodeFromString>(spriteJson) + + val spriteImageBytes = withContext(IODispatcher) { + loadResource(imageUrl)?.buffered()?.readByteArray() ?: throw Exception("Sprite image not found") + } + val spriteImage = byteArrayToImageBitmap(spriteImageBytes) + + Result.success(SpriteManager(spriteIndex, spriteImage)) + } catch (e: Exception) { + println("Failed to load sprite: ${e.message}") + Result.failure(e) + } + + } + } +} + +data class SDF( + val haloColor: Color = Color.Unspecified, + val haloWidth: Float = 0.02f, + val threshold: Float = 0.729f, + val fillColor: Color +) \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/TileCache.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/TileCache.kt new file mode 100644 index 00000000..d7491416 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/TileCache.kt @@ -0,0 +1,7 @@ +package ovh.plrapps.mapcompose.vector.data + +interface TileCache { + suspend fun get(sourceName: String, key: String): ByteArray? + suspend fun put(sourceName: String, key: String, data: ByteArray) + suspend fun clear() +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.kt new file mode 100644 index 00000000..46cf7a07 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.kt @@ -0,0 +1,5 @@ +package ovh.plrapps.mapcompose.vector.data.extension + +import androidx.compose.ui.graphics.ImageBitmap + +expect fun ImageBitmap.toBytes(): ByteArray? \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ViewportInfo.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ViewportInfo.kt new file mode 100644 index 00000000..e9f26143 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ViewportInfo.kt @@ -0,0 +1,13 @@ +package ovh.plrapps.mapcompose.vector.data.extension + +import ovh.plrapps.mapcompose.vector.core.ViewportInfo +import ovh.plrapps.mapcompose.vector.renderer.utils.MVTViewport + +fun ViewportInfo.toMVTViewport() = MVTViewport( + width = (this.size.width).toFloat(), + height = (this.size.height).toFloat(), + bearing = this.angleRad, + pitch = this.pitch, + zoom = this.zoom.toFloat(), + tileMatrix = this.matrix +) \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/getMapLibreConfiguration.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/getMapLibreConfiguration.kt new file mode 100644 index 00000000..1c89adc9 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/getMapLibreConfiguration.kt @@ -0,0 +1,69 @@ +package ovh.plrapps.mapcompose.vector.data + +import kotlinx.coroutines.withContext +import kotlinx.io.RawSource +import kotlinx.io.buffered +import kotlinx.io.readString +import ovh.plrapps.mapcompose.utils.IODispatcher +import ovh.plrapps.mapcompose.vector.spec.style.MapLibreStyle +import ovh.plrapps.mapcompose.vector.spec.style.sprites +import ovh.plrapps.mapcompose.vector.spec.style.utils.StyleDiagnostics +import ovh.plrapps.mapcompose.vector.spec.tilejson.TileJson + +suspend fun getMapLibreConfiguration( + style: String, + pixelRatio: Int = 1, + loadResource: suspend (String) -> RawSource? +): Result { + try { + StyleDiagnostics.drain() // discard anything left over from an earlier parse + val style = json.decodeFromString(MapLibreStyle.serializer(), style) + val diagnostics = StyleDiagnostics.drain() + val tileSources = mutableMapOf() + + style.sources?.toList()?.forEach { (name, source) -> + val sourceUrl = source.url + val tiles = source.tiles + if (sourceUrl !== null) { + val tileJson = getTileJson(sourceUrl, loadResource).getOrElse { e -> return Result.failure(e) } + tileSources[name] = MapLibreTileSource(tileJson) + } else if(tiles != null) { + tileSources[name] = MapLibreTileSource( + TileJson( + tilejson = "2.0.0", + tiles = tiles, + maxzoom = source.maxzoom ?: 22, + minzoom = source.minzoom ?: 0, + ) + ) + } + } + + val spriteManager = style.sprites.firstOrNull()?.url?.let { sprite -> + SpriteManager.load(spriteUrl = sprite, pixelRatio = pixelRatio, loadResource = loadResource).getOrElse { e -> return Result.failure(e) } + } + + return Result.success(MapLibreConfiguration( + style = style, + tileSources = tileSources, + spriteManager = spriteManager, + diagnostics = diagnostics, + )) + + } catch (e: Exception) { + return Result.failure(e) + } +} + +suspend fun getTileJson(tileJsonUrl: String, loadResource: suspend (String) -> RawSource?): Result { + return try { + val rawTileJson = withContext(IODispatcher) { + loadResource(tileJsonUrl)?.buffered()?.readString() ?: throw Exception("TileJson not found") + } + val tileJson = json.decodeFromString(TileJson.serializer(), rawTileJson) + + Result.success(tileJson) + } catch (e: Exception) { + Result.failure(e) + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/BackgroundLayerPainter.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/BackgroundLayerPainter.kt new file mode 100644 index 00000000..d8fdad76 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/BackgroundLayerPainter.kt @@ -0,0 +1,34 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.BackgroundLayer +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsFloat +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsColor + +class BackgroundLayerPainter : BaseLayerPainter() { + override suspend fun paint( + canvas: DrawScope, + feature: Tile.Feature, + style: BackgroundLayer, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: EvalFeature?, + actualZoom: Double, + featureKey: String? + ) { + val paint = style.paint ?: return + + val backgroundColor = paint.backgroundColor?.processAsColor(featureProperties, zoom) ?: Color.White + val opacity = paint.backgroundOpacity.processAsFloat(featureProperties, zoom) ?: 1f + + canvas.drawRect( + color = backgroundColor.copy(alpha = opacity), + size = canvas.size + ) + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/BaseLayerPainter.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/BaseLayerPainter.kt new file mode 100644 index 00000000..3e4ba0fb --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/BaseLayerPainter.kt @@ -0,0 +1,44 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.Layer + +abstract class BaseLayerPainter { + protected val geometryDecoders = GeometryDecoders() + + abstract suspend fun paint( + canvas: DrawScope, + feature: Tile.Feature, + style: T, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: EvalFeature?, + actualZoom: Double, + featureKey: String? = null + ) + + protected fun createPath( + feature: Tile.Feature, + canvasSize: Int, + extent: Int + ): Path? { + return when (feature.type) { + Tile.GeomType.POLYGON -> { + val rings = geometryDecoders.decodePolygon(feature.geometry, canvasSize = canvasSize, extent = extent) + if (rings.isNotEmpty()) { + geometryDecoders.createPolygonPath(rings) + } else null + } + Tile.GeomType.LINESTRING -> geometryDecoders.createLineStringPath( + geometryDecoders.decodeLine(feature.geometry, canvasSize = canvasSize, extent = extent).flatten() + ) + Tile.GeomType.POINT -> null // TODO: Implement point rendering + else -> null + } + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/BaseRenderer.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/BaseRenderer.kt new file mode 100644 index 00000000..6f797473 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/BaseRenderer.kt @@ -0,0 +1,155 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.data.MapLibreConfiguration +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.Layer +import ovh.plrapps.mapcompose.vector.spec.style.expression.CanonicalTileId +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature +import ovh.plrapps.mapcompose.vector.spec.style.expression.GlobalProperties +import ovh.plrapps.mapcompose.vector.spec.style.expression.Point2D +import ovh.plrapps.mapcompose.vector.spec.style.expression.normalizeNumbers + +abstract class BaseRenderer( + private val configuration: MapLibreConfiguration, +) { + + /** + * Evaluates the layer's filter against a feature. + * + * The zoom passed to a filter is the tile's *integer* zoom, not the fractional map zoom. That + * matches MapLibre, where buckets evaluate `featureFilter.filter(new + * EvaluationParameters(this.zoom), …)` with the tile's canonical zoom while paint properties + * are evaluated at the fractional zoom. + */ + fun shouldRenderFeature( + feature: Tile.Feature, + tileLayer: Tile.Layer, + styleLayer: Layer, + zoom: Double, + evalFeature: EvalFeature? = null, + canonical: CanonicalTileId? = null, + ): Boolean { + val filter = styleLayer.filter?.filter ?: return true + val target = evalFeature ?: buildEvalFeature(feature, tileLayer, filter.needGeometry) + return filter.filter( + globals = GlobalProperties(zoom = zoom.toInt().toDouble()), + feature = target, + canonical = canonical, + ) + } + + private val MAX_ZOOM = 30.0 + + fun isZoomInRange(styleLayer: Layer, zoom: Double): Boolean { + val minZoom = styleLayer.minzoom ?: 0.0 + val maxZoom = styleLayer.maxzoom ?: MAX_ZOOM + return zoom in minZoom.. "LineString" + Tile.GeomType.POINT -> "Point" + Tile.GeomType.POLYGON -> "Polygon" + Tile.GeomType.UNKNOWN -> "Unknown" + is Tile.GeomType.UNRECOGNIZED -> "Unknown" + null -> "Unknown" + } + + /** + * Feature properties, with every numeric value normalized to [Double]. + * + * The expression engine models numbers the way JavaScript does, so MVT's Int/Float/Long/UInt + * variants must not leak in — see the note on `normalizeNumbers`. This is what makes + * `["==", ["get", "n"], 1]` match a property the tile encoded as a float. + */ + fun extractFeatureProperties(feature: Tile.Feature, tileLayer: Tile.Layer): Map { + val props = mutableMapOf() + val keys = tileLayer.keys + val values = tileLayer.values + for (i in feature.tags.indices step 2) { + val keyIdx = feature.tags[i] + val valueIdx = feature.tags[i + 1] + if (keyIdx < keys.size && valueIdx < values.size) { + val key = keys[keyIdx] + val value = values[valueIdx] + val raw = value.stringValue ?: value.floatValue ?: value.doubleValue ?: value.intValue + ?: value.uintValue ?: value.sintValue ?: value.boolValue + props[key] = normalizeNumbers(raw) + } + } + return props + } + + /** + * Decodes MVT geometry commands into rings of tile-local coordinates. + * + * Unlike [GeometryDecoders], which scales to canvas pixels, this keeps the raw 0..extent tile + * coordinate space that `within` and `distance` are defined in. + */ + private fun decodeRawGeometry(geometry: List): List> { + val rings = mutableListOf>() + var current = mutableListOf() + var x = 0 + var y = 0 + var i = 0 + + while (i < geometry.size) { + val commandInteger = geometry[i++] + val commandId = commandInteger and 0x7 + val count = commandInteger shr 3 + + when (commandId) { + 1 -> { // MoveTo + repeat(count) { + if (i + 1 >= geometry.size) return@repeat + if (current.isNotEmpty()) { + rings.add(current) + current = mutableListOf() + } + x += GeometryDecoders.decodeZigZag(geometry[i++]) + y += GeometryDecoders.decodeZigZag(geometry[i++]) + current.add(Point2D(x.toDouble(), y.toDouble())) + } + } + + 2 -> { // LineTo + repeat(count) { + if (i + 1 >= geometry.size) return@repeat + x += GeometryDecoders.decodeZigZag(geometry[i++]) + y += GeometryDecoders.decodeZigZag(geometry[i++]) + current.add(Point2D(x.toDouble(), y.toDouble())) + } + } + + 7 -> { // ClosePath + if (current.isNotEmpty()) current.add(current.first()) + } + } + } + + if (current.isNotEmpty()) rings.add(current) + return rings + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/CircleLayerPainter.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/CircleLayerPainter.kt new file mode 100644 index 00000000..d83bad3f --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/CircleLayerPainter.kt @@ -0,0 +1,52 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.CircleLayer +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsFloat +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsColor + +class CircleLayerPainter : BaseLayerPainter() { + override suspend fun paint( + canvas: DrawScope, + feature: Tile.Feature, + style: CircleLayer, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: EvalFeature?, + actualZoom: Double, + featureKey: String? + ) { + if (feature.type != Tile.GeomType.POINT) return + + val paint = style.paint ?: return + val path = createPath(feature, canvasSize, extent) ?: return + + val circleColor = paint.circleColor?.processAsColor(featureProperties, zoom) ?: Color.Black + val circleOpacity = paint.circleOpacity.processAsFloat(featureProperties, zoom) ?: 1f + val circleRadius = paint.circleRadius.processAsFloat(featureProperties, zoom) ?: 5f + val circleStrokeWidth = paint.circleStrokeWidth.processAsFloat(featureProperties, zoom) ?: 0f + val circleStrokeColor = paint.circleStrokeColor?.processAsColor(featureProperties, zoom) ?: Color.Black + + canvas.drawPath( + path = path, + color = circleColor.copy(alpha = circleOpacity) + ) + + if (circleStrokeWidth > 0f) { + canvas.drawPath( + path = path, + color = circleStrokeColor.copy(alpha = circleOpacity), + style = Stroke( + width = circleStrokeWidth, + pathEffect = null + ) + ) + } + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/FillExtrusionPainter.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/FillExtrusionPainter.kt new file mode 100644 index 00000000..4dcf1e4e --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/FillExtrusionPainter.kt @@ -0,0 +1,27 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.graphics.drawscope.DrawScope +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.FillExtrusionLayer + +class FillExtrusionPainter : BaseLayerPainter() { + override suspend fun paint( + canvas: DrawScope, + feature: Tile.Feature, + style: FillExtrusionLayer, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: EvalFeature?, + actualZoom: Double, + featureKey: String? + ) { + // TODO: Implement 3D extrusion rendering + // 1. Get height from properties + // 2. Create 3D geometry + // 3. Apply materials and lighting + // 4. Draw with perspective + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/FillLayerPainter.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/FillLayerPainter.kt new file mode 100644 index 00000000..60e0939f --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/FillLayerPainter.kt @@ -0,0 +1,103 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.FillLayer +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsFloat +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsColor +import ovh.plrapps.mapcompose.vector.utils.LruCache +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class FillLayerPainter( + private val pathCache: LruCache? = null, + private val mutex: Mutex? = null +) : BaseLayerPainter() { + override suspend fun paint( + canvas: DrawScope, + feature: Tile.Feature, + style: FillLayer, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: EvalFeature?, + actualZoom: Double, + featureKey: String? + ) { + if (feature.type != Tile.GeomType.POLYGON) return + + val paint = style.paint ?: return + + val path: Path? = if (featureKey != null && pathCache != null && mutex != null) { + mutex.withLock { + pathCache.get(featureKey) as? Path + } ?: createPath(feature, canvasSize, extent)?.also { + mutex.withLock { + pathCache.put(featureKey, it) + } + } + } else { + createPath(feature, canvasSize, extent) + } + + if (path == null) return + + // FIXME: It's broken previous layers + if (paint.fillPattern != null) { + return + } + val fillColor = paint.fillColor?.processAsColor(featureProperties, actualZoom) ?: Color.Transparent + val fillOpacity = paint.fillOpacity.processAsFloat(featureProperties, actualZoom) ?: 1f + val fillOutlineColor = paint.fillOutlineColor?.processAsColor(featureProperties, actualZoom) ?: Color.Transparent + + canvas.drawPath( + path = path, + color = fillColor.copy(alpha = fillOpacity) + ) + + if (paint.fillOutlineColor != null) { + canvas.drawPath( + path = path, + color = fillOutlineColor, + style = Stroke( + width = canvas.density, + pathEffect = null + ) + ) + } + } + + private fun applyTranslation(path: Path, translate: JsonElement?): Path { + if (translate == null) return path + + val offset = when (translate) { + is JsonPrimitive -> { + val value = translate.content.toFloatOrNull() ?: 0f + Offset(value, value) + } + + is JsonObject -> { + val x = translate["x"]?.toString()?.toFloatOrNull() ?: 0f + val y = translate["y"]?.toString()?.toFloatOrNull() ?: 0f + Offset(x, y) + } + + else -> Offset.Zero + } + + if (offset == Offset.Zero) return path + + val translatedPath = Path() + translatedPath.addPath(path, offset) + return translatedPath + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/GeometryDecoders.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/GeometryDecoders.kt new file mode 100644 index 00000000..f43c3278 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/GeometryDecoders.kt @@ -0,0 +1,214 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import androidx.compose.ui.graphics.Path + +data class Point(val x: Double, val y: Double) + +class GeometryDecoders { + companion object { + internal fun decodeZigZag(n: Int): Int = (n ushr 1) xor (-(n and 1)) + internal fun tileCoordToCanvas( + x: Int, + y: Int, + canvasSize: Int, + extent: Int + ): Pair { + val scale = canvasSize.toFloat() / extent + val result = Pair(x * scale, y * scale) + return result + } + } + + fun decodePolygon( + geometry: List, + extent: Int, + canvasSize: Int + ): List>> { + val rings = mutableListOf>>() + var x = 0 + var y = 0 + var i = 0 + var currentRing: MutableList>? = null + val scale = canvasSize.toFloat() / extent + while (i < geometry.size) { + val cmdInteger = geometry[i++] + val command = cmdInteger and 0x7 + val count = cmdInteger shr 3 + when (command) { + 1 -> { // MoveTo + if (count < 1) continue + currentRing = mutableListOf() + for (j in 0 until count) { + if (i + 1 >= geometry.size) break + x += decodeZigZag(geometry[i++]) + y += decodeZigZag(geometry[i++]) + currentRing.add(Pair(x * scale, y * scale)) + } + } + 2 -> { // LineTo + if (currentRing == null) break + for (j in 0 until count) { + if (i + 1 >= geometry.size) break + x += decodeZigZag(geometry[i++]) + y += decodeZigZag(geometry[i++]) + currentRing.add(Pair(x * scale, y * scale)) + } + } + 7 -> { // ClosePath + if (currentRing != null && currentRing.isNotEmpty()) { + currentRing.add(currentRing[0]) + rings.add(currentRing) + currentRing = null + } + } + else -> break + } + } + return rings + } + + fun decodeLine( + geometry: List, + extent: Int, + canvasSize: Int + ): List>> { + val lines = mutableListOf>>() + var x = 0 + var y = 0 + var i = 0 + var currentLine: MutableList>? = null + val scale = canvasSize.toFloat() / extent + while (i < geometry.size) { + val cmdInteger = geometry[i++] + val command = cmdInteger and 0x7 + val count = cmdInteger shr 3 + when (command) { + 1 -> { // MoveTo + if (count < 1) continue + if (currentLine != null && currentLine.isNotEmpty()) { + lines.add(currentLine) + } + currentLine = mutableListOf() + for (j in 0 until count) { + if (i + 1 >= geometry.size) break + x += decodeZigZag(geometry[i++]) + y += decodeZigZag(geometry[i++]) + currentLine.add(Pair(x * scale, y * scale)) + } + } + 2 -> { // LineTo + if (currentLine == null) break + for (j in 0 until count) { + if (i + 1 >= geometry.size) break + x += decodeZigZag(geometry[i++]) + y += decodeZigZag(geometry[i++]) + currentLine.add(Pair(x * scale, y * scale)) + } + } + 7 -> { // ClosePath + // For lines ClosePath is ignored + } + else -> break + } + } + if (currentLine != null && currentLine.isNotEmpty()) { + lines.add(currentLine) + } + return lines + } + + fun createLineStringPath(points: List>): Path { + val path = Path() + if (points.isEmpty()) return path + + var isFirst = true + for (point in points) { + if (isFirst) { + path.moveTo(point.first, point.second) + isFirst = false + } else { + path.lineTo(point.first, point.second) + } + } + return path + } + + fun createPolygonPath(rings: List>>): Path { + val path = Path() + for (ring in rings) { + if (ring.isEmpty()) continue + + var isFirst = true + for (point in ring) { + if (isFirst) { + path.moveTo(point.first, point.second) + isFirst = false + } else { + path.lineTo(point.first, point.second) + } + } + path.close() + } + return path + } + + fun calculateCentroid(points: List): Point { + var sumX = 0.0 + var sumY = 0.0 + for (point in points) { + sumX += point.x + sumY += point.y + } + return Point(sumX / points.size, sumY / points.size) + } + + fun calculateCentroid(path: Path): Point { + val bounds = path.getBounds() + return Point((bounds.left + bounds.width / 2).toDouble(), (bounds.top + bounds.height / 2f).toDouble()) + } + + fun decodePoint( + geometry: List, + extent: Int = 4096, + canvasSize: Int = 256 + ): List { + val points = mutableListOf() + var x = 0 + var y = 0 + var i = 0 + while (i < geometry.size) { + if (i >= geometry.size) break + val cmdInteger = geometry[i++] + val command = cmdInteger and 0x7 + val count = cmdInteger shr 3 + when (command) { + 1 -> { // MoveTo + for (j in 0 until count) { + if (i + 1 >= geometry.size) break + val dx = geometry[i++] + val dy = geometry[i++] + x += decodeZigZag(dx) + y += decodeZigZag(dy) + val point = tileCoordToCanvas(x = x, y = y, canvasSize = canvasSize, extent = extent) + points.add(Point(point.first.toDouble(), point.second.toDouble())) + } + } + else -> break + } + } + return points + } + + /** + * Returns a list of polygons (each a list of rings). + * For Polygon, a list of one element. + * For MultiPolygon, a list of all polygons. + */ + fun decodePolygons( + geometry: List, + extent: Int, + canvasSize: Int + ): List>>> { + return listOf(decodePolygon(geometry, extent, canvasSize)) + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/HeatmapLayerPainter.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/HeatmapLayerPainter.kt new file mode 100644 index 00000000..bdb73c62 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/HeatmapLayerPainter.kt @@ -0,0 +1,27 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.graphics.drawscope.DrawScope +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.HeatmapLayer + +class HeatmapLayerPainter : BaseLayerPainter() { + override suspend fun paint( + canvas: DrawScope, + feature: Tile.Feature, + style: HeatmapLayer, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: EvalFeature?, + actualZoom: Double, + featureKey: String? + ) { + // TODO: Implement heatmap rendering + // 1. Collect all points with weights + // 2. Apply Gaussian blur + // 3. Normalize values + // 4. Apply color scheme + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/HillshadeLayerPainter.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/HillshadeLayerPainter.kt new file mode 100644 index 00000000..229abb56 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/HillshadeLayerPainter.kt @@ -0,0 +1,27 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.graphics.drawscope.DrawScope +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.HillshadeLayer + +class HillshadeLayerPainter : BaseLayerPainter() { + override suspend fun paint( + canvas: DrawScope, + feature: Tile.Feature, + style: HillshadeLayer, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: EvalFeature?, + actualZoom: Double, + featureKey: String? + ) { + // TODO: Implement terrain rendering + // 1. Get height data + // 2. Calculate normals for each point + // 3. Apply lighting + // 4. Draw with shadow + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/LineLayerPainter.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/LineLayerPainter.kt new file mode 100644 index 00000000..9823944b --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/LineLayerPainter.kt @@ -0,0 +1,343 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.translate +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.LineLayer +import ovh.plrapps.mapcompose.vector.spec.style.line.LineLayout +import ovh.plrapps.mapcompose.vector.spec.style.line.LinePaint +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsDouble +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsDoubleList +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsFloat +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsColor +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsString +import ovh.plrapps.mapcompose.vector.utils.LruCache +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.math.sqrt + +class LineLayerPainter( + private val pathCache: LruCache? = null, + private val mutex: Mutex? = null +) : BaseLayerPainter() { + override suspend fun paint( + canvas: DrawScope, + feature: Tile.Feature, + style: LineLayer, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: EvalFeature?, + actualZoom: Double, + featureKey: String? + ) { + if (feature.type != Tile.GeomType.LINESTRING && feature.type != Tile.GeomType.POLYGON) return + + val paint = style.paint ?: LinePaint() + val layout = style.layout + + val path: Path? = if (featureKey != null && pathCache != null && mutex != null) { + mutex.withLock { + pathCache.get(featureKey) as? Path + } ?: createPath(feature, canvasSize, extent)?.also { + mutex.withLock { + pathCache.put(featureKey, it) + } + } + } else { + createPath(feature, canvasSize, extent) + } + + if (path == null) return + + val lineColor = + paint.lineColor?.processAsColor(feature = featureProperties, zoom = actualZoom) ?: Color.Black + val lineOpacity = paint.lineOpacity.processAsFloat(featureProperties, actualZoom) ?: 1f + val lineWidth = paint.lineWidth.processAsFloat(featureProperties, actualZoom)?.let { it * canvas.density } ?: 1f + val lineGapWidth = paint.lineGapWidth.processAsFloat(featureProperties, actualZoom) ?: 0f + val lineBlur = paint.lineBlur.processAsFloat(featureProperties, actualZoom) ?: 0f + val lineOffset = paint.lineOffset.processAsFloat(featureProperties, actualZoom) ?: 0f + val lineTranslate = + paint.lineTranslate.processAsDoubleList(featureProperties, actualZoom)?.map { it.toFloat() } ?: listOf(0f, 0f) + val lineTranslateAnchor = paint.lineTranslateAnchor?.processAsString(featureProperties, actualZoom) ?: "map" + val lineCap = getLineCap(layout, actualZoom, featureProperties) + val lineJoin = getLineJoin(layout, actualZoom, featureProperties) + val lineMiterLimit = layout?.lineMiterLimit.processAsFloat(featureProperties, actualZoom) ?: 2f + val lineRoundLimit = layout?.lineRoundLimit.processAsDouble(featureProperties, actualZoom) ?: 1.0 + val dashArray = getLineDashArray(paint, actualZoom, featureProperties) + + // TODO rewrite + val lineWidthPx = lineWidth + val lineGapWidthPx = lineGapWidth + val lineBlurPx = lineBlur + val lineOffsetPx = lineOffset + val dashArrayPx = dashArray?.map { it.toFloat() * canvas.density }?.toFloatArray() + + if (feature.type == Tile.GeomType.POLYGON) { + val polygons = geometryDecoders.decodePolygons(feature.geometry, canvasSize = canvasSize, extent = extent) + // println("[LineLayerPainter] POLYGON/MULTIPOLYGON: polygons.size = ${polygons.size}") + for ((polyIdx, rings) in polygons.withIndex()) { + for ((ringIdx, ring) in rings.withIndex()) { + if (ring.isNotEmpty()) { + // println("[LineLayerPainter] Polygon #$polyIdx, ring #$ringIdx: size = ${ring.size}") + val path = createPathFromPoints(ring, lineOffsetPx) + if (lineGapWidthPx > 0) { + // First, draw a gap (a wide line in the base color) + drawPathWithBlur( + canvas = canvas, + path = path, + color = Color.Black, // TODO + opacity = 1f, + width = lineWidthPx + lineGapWidthPx * 2, + blur = lineBlurPx, + translate = lineTranslate, + translateAnchor = lineTranslateAnchor, + cap = lineCap, + join = lineJoin, + miterLimit = lineMiterLimit, + roundLimit = lineRoundLimit, + dashArray = dashArrayPx + ) + + drawPathWithBlur( + canvas = canvas, + path = path, + color = lineColor, + opacity = lineOpacity, + width = lineWidthPx, + blur = lineBlurPx, + translate = lineTranslate, + translateAnchor = lineTranslateAnchor, + cap = lineCap, + join = lineJoin, + miterLimit = lineMiterLimit, + roundLimit = lineRoundLimit, + dashArray = dashArrayPx + ) + } else { + drawPathWithBlur( + canvas = canvas, + path = path, + color = lineColor, + opacity = lineOpacity, + width = lineWidthPx, + blur = lineBlurPx, + translate = lineTranslate, + translateAnchor = lineTranslateAnchor, + cap = lineCap, + join = lineJoin, + miterLimit = lineMiterLimit, + roundLimit = lineRoundLimit, + dashArray = dashArrayPx + ) + } + } + } + } + } else { + val path = createPath(feature, canvasSize, extent, lineOffsetPx) ?: return + drawPathWithBlur( + canvas = canvas, + path = path, + color = lineColor, + opacity = lineOpacity, + width = lineWidthPx, + blur = lineBlurPx, + translate = lineTranslate, + translateAnchor = lineTranslateAnchor, + cap = lineCap, + join = lineJoin, + miterLimit = lineMiterLimit, + roundLimit = lineRoundLimit, + dashArray = dashArrayPx + ) + } + + if (style.id == "geolines") { + println("DEBUG!") + } + } + + private fun drawPathWithBlur( + canvas: DrawScope, + path: Path, + color: Color, + opacity: Float, + width: Float, + blur: Float, + translate: List, + translateAnchor: String, + cap: StrokeCap, + join: StrokeJoin, + miterLimit: Float, + roundLimit: Double, + dashArray: FloatArray? + ) { + val (dx, dy) = when (translateAnchor) { + "viewport" -> translate + else -> translate // "map" + } + + canvas.translate(dx, dy) { + if (blur > 0) { + val blurSteps = 5 + val blurStep = blur / blurSteps + val opacityStep = opacity / blurSteps + + for (i in 0 until blurSteps) { + val currentBlur = blurStep * (i + 1) + val currentOpacity = opacityStep * (i + 1) + val currentWidth = width + currentBlur * 2 + + canvas.drawPath( + path = path, + color = color.copy(alpha = currentOpacity), + style = Stroke( + width = currentWidth, + cap = cap, + join = if (join == StrokeJoin.Round && width <= roundLimit) StrokeJoin.Round else StrokeJoin.Miter, + miter = miterLimit, + pathEffect = dashArray?.let { createDashPathEffect(it) } + ) + ) + } + } else { + canvas.drawPath( + path = path, + color = color.copy(alpha = opacity), + style = Stroke( + width = width, + cap = cap, + join = if (join == StrokeJoin.Round && width <= roundLimit) StrokeJoin.Round else StrokeJoin.Miter, + miter = miterLimit, + pathEffect = dashArray?.let { createDashPathEffect(it) } + ) + ) + } + } + } + + private fun createPathFromPoints(points: List>, offset: Float): Path { + if (offset == 0f) { + val path = Path() + var isFirst = true + for (point in points) { + if (isFirst) { + path.moveTo(point.first, point.second) + isFirst = false + } else { + path.lineTo(point.first, point.second) + } + } + return path + } + + val path = Path() + var isFirst = true + var prevPoint: Pair? = null + var prevNormal: Pair? = null + + for (i in points.indices) { + val currentPoint = points[i] + val nextPoint = if (i < points.size - 1) points[i + 1] else null + + if (isFirst) { + path.moveTo(currentPoint.first, currentPoint.second) + isFirst = false + } else { + // Calculate the normal for the current segment + val normal = if (nextPoint != null) { + val dx = nextPoint.first - currentPoint.first + val dy = nextPoint.second - currentPoint.second + val length = sqrt(dx * dx + dy * dy) + if (length > 0) { + Pair(-dy / length, dx / length) + } else { + Pair(0f, 0f) + } + } else { + prevNormal ?: Pair(0f, 0f) + } + + val offsetPoint = Pair( + currentPoint.first + normal.first * offset, + currentPoint.second + normal.second * offset + ) + + path.lineTo(offsetPoint.first, offsetPoint.second) + prevNormal = normal + } + prevPoint = currentPoint + } + + return path + } + + private fun createPath(feature: Tile.Feature, canvasSize: Int, extent: Int, offset: Float): Path? { + return when (feature.type) { + Tile.GeomType.LINESTRING -> { + val lines = geometryDecoders.decodeLine(feature.geometry, canvasSize = canvasSize, extent = extent) + if (lines.isNotEmpty()) { + val path = Path() + for (line in lines) { + if (line.isNotEmpty()) { + var isFirst = true + for (point in line) { + if (isFirst) { + path.moveTo(point.first, point.second) + isFirst = false + } else { + path.lineTo(point.first, point.second) + } + } + } + } + path + } else null + } + + else -> super.createPath(feature, canvasSize, extent) + } + } + + private fun createDashPathEffect(dashArray: FloatArray): PathEffect { + return PathEffect.dashPathEffect( + intervals = dashArray, + phase = 0f + ) + } + + private fun getLineCap(layout: LineLayout?, actualZoom: Double, featureProperties: EvalFeature?): StrokeCap { + return when (layout?.lineCap?.processAsString(feature = featureProperties, zoom = actualZoom) ?: "butt") { + "butt" -> StrokeCap.Butt + "round" -> StrokeCap.Round + "square" -> StrokeCap.Square + else -> StrokeCap.Butt + } + } + + private fun getLineJoin( + layout: LineLayout?, + actualZoom: Double, + featureProperties: EvalFeature? + ): StrokeJoin { + return when (layout?.lineJoin?.processAsString(feature = featureProperties, zoom = actualZoom) ?: "miter") { + "bevel" -> StrokeJoin.Bevel + "round" -> StrokeJoin.Round + "miter" -> StrokeJoin.Miter + else -> StrokeJoin.Miter + } + } + + private fun getLineDashArray( + paint: LinePaint, + actualZoom: Double, + featureProperties: EvalFeature? + ): DoubleArray? { + return paint.lineDasharray.processAsDoubleList(feature = featureProperties, zoom = actualZoom)?.toDoubleArray() + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/RasterLayerPainter.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/RasterLayerPainter.kt new file mode 100644 index 00000000..acfb67c2 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/RasterLayerPainter.kt @@ -0,0 +1,26 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.graphics.drawscope.DrawScope +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.RasterLayer + +class RasterLayerPainter : BaseLayerPainter() { + override suspend fun paint( + canvas: DrawScope, + feature: Tile.Feature, + style: RasterLayer, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: EvalFeature?, + actualZoom: Double, + featureKey: String? + ) { + // TODO: Implement bitmap layer rendering + // 1. Load bitmap + // 2. Apply transformations + // 3. Draw with transparency + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/SkyLayerPainter.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/SkyLayerPainter.kt new file mode 100644 index 00000000..2f3d1e86 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/SkyLayerPainter.kt @@ -0,0 +1,26 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.graphics.drawscope.DrawScope +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.SkyLayer + +class SkyLayerPainter : BaseLayerPainter() { + override suspend fun paint( + canvas: DrawScope, + feature: Tile.Feature, + style: SkyLayer, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: EvalFeature?, + actualZoom: Double, + featureKey: String? + ) { + // TODO: Implement sky rendering + // 1. Create a sky gradient + // 2. Add atmospheric effects + // 3. Apply lighting + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/SymbolLayerPainter.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/SymbolLayerPainter.kt new file mode 100644 index 00000000..28442ac3 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/SymbolLayerPainter.kt @@ -0,0 +1,1224 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.text.* +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.flow.MutableStateFlow +import ovh.plrapps.mapcompose.vector.data.MapLibreConfiguration +import ovh.plrapps.mapcompose.vector.data.SDF +import ovh.plrapps.mapcompose.vector.data.SpriteManager +import ovh.plrapps.mapcompose.vector.renderer.collision.LabelPlacement +import ovh.plrapps.mapcompose.vector.renderer.collision.LineLabelPlacement +import ovh.plrapps.mapcompose.vector.renderer.collision.OverlapMode +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.SymbolLayer +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsDouble +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsDoubleList +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsFloat +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsString +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsBoolean +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsStringList +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsColor +import ovh.plrapps.mapcompose.vector.spec.style.symbol.SymbolLayout +import ovh.plrapps.mapcompose.vector.spec.style.symbol.TextAnchor +import ovh.plrapps.mapcompose.vector.utils.LruCache +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import androidx.compose.ui.graphics.toArgb +import ovh.plrapps.mapcompose.vector.utils.obb.OBB +import ovh.plrapps.mapcompose.vector.utils.obb.ObbPoint +import kotlin.collections.zipWithNext +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.sqrt + +data class CompoundLabelPlacement( + val spritePlacement: LabelPlacement, + val textPlacement: LabelPlacement? +) + +data class TextPlacementCandidate( + val labelPlacement: LabelPlacement, + val mercatorX: Double, + val mercatorY: Double, + val dx: Float, + val dy: Float, +) + +sealed class Symbol( + val id: String, + val global: Point, + val placement: CompoundLabelPlacement, + val align: Offset = IN_CENTER, + val viewportAligned: Boolean = true, +) { + class Sprite( + id: String, + global: Point, + placement: CompoundLabelPlacement, + val value: ImageBitmap, + viewportAligned: Boolean = true, + ) : Symbol(id, global, placement, viewportAligned = viewportAligned) + + class Text( + id: String, + global: Point, + placement: CompoundLabelPlacement, + val value: TextLayoutResult, + viewportAligned: Boolean = true, + val spriteAnchorGlobal: Point? = null, + val textOffset: Pair? = null, + ) : Symbol(id, global, placement, viewportAligned = viewportAligned) + + class SpriteWithText( + id: String, + global: Point, + placement: CompoundLabelPlacement, + align: Offset, + val sprite: ImageBitmap, + val text: TextLayoutResult, + val spriteSize: IntSize, + val textSize: IntSize, + val verticalGap: Float, + val iconOptional: Boolean, + val textOptional: Boolean, + viewportAligned: Boolean = true, + val textCandidates: List = emptyList(), + ) : Symbol(id, global, placement, align, viewportAligned) + + fun draw(drawScope: DrawScope) { + val s = getInPixels() + val symbolCenter = Offset(s.width / 2f, s.height / 2f) + with(drawScope) { + when (this@Symbol) { + is Sprite -> { + rotate(placement.spritePlacement.angle, pivot = symbolCenter) { + drawImage( + image = value, + dstSize = IntSize( + placement.spritePlacement.bounds.width.toInt(), + placement.spritePlacement.bounds.height.toInt() + ) + ) + } + } + + is Text -> { + // For text, use the corner from textPlacement (if available) or spritePlacement + val textAngle = + placement.textPlacement?.angle ?: placement.spritePlacement.angle + rotate(textAngle, pivot = symbolCenter) { + val centerX = (s.width - value.size.width) / 2f + val centerY = (s.height - value.size.height) / 2f + val topLeft = Offset(centerX, centerY) + + drawText( + textLayoutResult = value, + topLeft = topLeft + ) + } + } + + is SpriteWithText -> { + rotate(placement.spritePlacement.angle, pivot = symbolCenter) { + // For SpriteWithText offset is already taken into account in the position, you need to correctly place the elements inside the Canvas + // The sprite should be horizontally centered + val spriteLeft = (s.width - spriteSize.width) / 2f + val spriteTop = 0f + + // The text should be centered relative to the sprite + val textLeft = (s.width - textSize.width) / 2f + val textTop = spriteSize.height + verticalGap + + drawImage( + image = sprite, + dstSize = spriteSize, + dstOffset = IntOffset(spriteLeft.toInt(), spriteTop.toInt()) + ) + + // Draw text under the sprite + drawText( + textLayoutResult = text, + topLeft = Offset(textLeft, textTop) + ) + } + } + } + } + } + + fun getInPixels(): Size { + return when (this) { + is SpriteWithText -> { + val totalWidth = max(spriteSize.width, textSize.width) + val totalHeight = (spriteSize.height + verticalGap + textSize.height) + Size(totalWidth.toFloat(), totalHeight) + } + + is Sprite -> { + Size( + placement.spritePlacement.bounds.width, + placement.spritePlacement.bounds.height + ) + } + + is Text -> { + Size( + placement.textPlacement!!.bounds.width, + placement.textPlacement.bounds.height + ) + } + } + } + + companion object { + val IN_CENTER = Offset(-0.5f, -0.5f) + } +} + +data class SymbolPlacement( + val position: ObbPoint, + val angle: Float, + val size: Size = Size.Zero +) + +class SymbolLayerPainter( + private val textMeasurerState: MutableStateFlow, + private val spriteManager: SpriteManager?, + private val configuration: MapLibreConfiguration, + private val pathCache: LruCache, + private val mutex: Mutex +) { + private val DEFAULT_ICON_SCALE = 1f + + private val geometryDecoders = GeometryDecoders() + + private fun resolveViewportAligned(alignmentValue: String?, defaultViewportAligned: Boolean): Boolean = + when (alignmentValue) { + "map" -> false + "viewport" -> true + else -> defaultViewportAligned + } + + private fun resolveIconOverlapMode( + layout: SymbolLayout, + props: EvalFeature?, + zoom: Double + ): OverlapMode = + layout.iconOverlap?.processAsString(props, zoom)?.let { v -> + when (v) { + "always" -> OverlapMode.Always + "cooperative" -> OverlapMode.Cooperative + else -> OverlapMode.Never + } + } ?: if (layout.iconAllowOverlap?.processAsBoolean(props, zoom) == true) OverlapMode.Always else OverlapMode.Never + + private fun resolveTextOverlapMode( + layout: SymbolLayout, + props: EvalFeature?, + zoom: Double + ): OverlapMode = + layout.textOverlap?.processAsString(props, zoom)?.let { v -> + when (v) { + "always" -> OverlapMode.Always + "cooperative" -> OverlapMode.Cooperative + else -> OverlapMode.Never + } + } ?: if (layout.textAllowOverlap?.processAsBoolean(props, zoom) == true) OverlapMode.Always else OverlapMode.Never + + /** + * Transformation to normalized Mercator coordinates + */ + private fun tileCoordToNormalized( + tileX: Int, + tileY: Int, + pixelX: Double, + pixelY: Double, + zoom: Double, + tileSize: Int + ): Point { + val n = 2.0.pow(zoom) + + val normalizedX = (tileX * tileSize + pixelX) / (tileSize * n) + val normalizedY = (tileY * tileSize + pixelY) / (tileSize * n) + + return Point(normalizedX, normalizedY) + } + + private fun substituteTemplate(template: String, properties: Map?): String { + val lang = configuration.lang?.code + if (properties == null) return template + + // Without curly braces - just substitute the desired name + if (!template.contains("{")) { + if (lang != null) { + val localized = properties["name:$lang"]?.toString() + if (!localized.isNullOrBlank()) return localized + } + return properties["name"]?.toString() ?: template + } + + // In the template - we substitute by key if it is name:lang, otherwise name, otherwise just by key + return regexForSubProcess.replace(template) { matchResult -> + val key = matchResult.groupValues[1] + if (lang != null && key == "name:$lang") { + val localized = properties["name:$lang"]?.toString() + if (!localized.isNullOrBlank()) return@replace localized + return@replace properties["name"]?.toString() ?: "" + } + if (key == "name") { + return@replace properties["name"]?.toString() ?: "" + } + properties[key]?.toString() ?: "" + } + } + + private fun calculatePointPlacement( + feature: Tile.Feature, + extent: Int, + canvasSize: Int + ): SymbolPlacement? { + val points = geometryDecoders.decodePoint(geometry = feature.geometry, extent = extent, canvasSize = canvasSize) + return points.firstOrNull()?.let { + SymbolPlacement( + position = ObbPoint(it.x.toFloat(), it.y.toFloat()), + angle = 0f + ) + } + } + + private val regexForSubProcess = "\\{([^}]+)\\}".toRegex() + + private fun subProcess( + input: String, + featureProperties: EvalFeature? + ): String { + return if (input.firstOrNull() == '{') { + val matchResult = regexForSubProcess.find(input) + if (matchResult != null) { + val key = matchResult.groupValues[1] + val propValue = featureProperties?.properties?.get(key)?.toString() ?: "" + input.replace("{$key}", propValue) + } else { + input + } + } else { + input + } + } + + private fun produceSprite( + placement: SymbolPlacement, + style: SymbolLayer, + featureProperties: EvalFeature?, + actualZoom: Double, + zoom: Double, + id: String, + canvasSize: Int, + tileX: Int, + tileY: Int, + density: Density, + layerIndex: Int + ): Symbol? { + val spriteManager = spriteManager ?: return null + val paint = style.paint ?: return null + val layout = style.layout ?: return null + + val spriteId = + layout.iconImage?.processAsString(featureProperties, actualZoom)?.let { subProcess(it, featureProperties) } + ?: return null + val spriteInfo = spriteManager.getSpriteInfo(spriteId) + ?: return null + + val iconScale: Float = layout.iconSize.processAsFloat(featureProperties, actualZoom) ?: DEFAULT_ICON_SCALE + val iconColor = paint.iconColor?.processAsColor(featureProperties, actualZoom) + val iconHaloColor = paint.iconHaloColor?.processAsColor(featureProperties, actualZoom) + val iconOpacity = paint.iconOpacity.processAsFloat(featureProperties, actualZoom) ?: 1f + if (iconOpacity == 0f) { + return null + } + val scale = iconScale * density.density + + val sdf = if (spriteInfo.sdf) { + iconColor?.let { fillColor -> + SDF(fillColor = fillColor) + }?.let { + if (iconHaloColor != null) { + it.copy(haloColor = iconHaloColor) + } else it + } + } else null + + val spritePair = spriteManager.getSprite(spriteId, iconColor, sdf) + if (spritePair == null) { + return null + } + val (spriteMeta, sprite) = spritePair + + val size = IntSize( + (spriteMeta.width.toFloat() * scale).toInt(), + (spriteMeta.height.toFloat() * scale).toInt() + ) + + val spritePosition = ObbPoint(placement.position.x, placement.position.y) + + // Direct conversion of tile coordinates to normalized MapCompose coordinates + val normalizedPoint = tileCoordToNormalized( + tileX = tileX, + tileY = tileY, + pixelX = spritePosition.x.toDouble(), + pixelY = spritePosition.y.toDouble(), + zoom = zoom, + tileSize = canvasSize + ) + val globalX = normalizedPoint.x + val globalY = normalizedPoint.y + + val iconPadding = (layout.iconPadding.processAsFloat(featureProperties, actualZoom) ?: 2f) * density.density + + // Use double for exact calculations, then convert to float + val leftBound = (spritePosition.x.toDouble() - size.width.toDouble() / 2.0).toFloat() - iconPadding + val topBound = (spritePosition.y.toDouble() - size.height.toDouble() / 2.0).toFloat() - iconPadding + val rightBound = (spritePosition.x.toDouble() + size.width.toDouble() / 2.0).toFloat() + iconPadding + val bottomBound = (spritePosition.y.toDouble() + size.height.toDouble() / 2.0).toFloat() + iconPadding + + val bounds = Rect( + left = leftBound, + top = topBound, + right = rightBound, + bottom = bottomBound + ) + + val iconRotateDeg = layout.iconRotate.processAsFloat(featureProperties, actualZoom) ?: 0f + val spriteAngle = placement.angle + iconRotateDeg + + return LabelPlacement( + text = "sprite_$spriteId", + position = ObbPoint( + spritePosition.x, + spritePosition.y + ), + angle = spriteAngle, + bounds = bounds, + obb = OBB( + ObbPoint(spritePosition.x, spritePosition.y), + ovh.plrapps.mapcompose.vector.utils.obb.Size(size.width + 2 * iconPadding, size.height + 2 * iconPadding), + spriteAngle + ), + layerIndex = layerIndex, + inLayerPriority = layout.symbolSortKey.processAsDouble(featureProperties, actualZoom) ?: 0.0, + overlapMode = resolveIconOverlapMode(layout, featureProperties, actualZoom), + ignorePlacement = layout.iconIgnorePlacement?.processAsBoolean(featureProperties, actualZoom) ?: false + ).let { + val isLinePlacement = layout.symbolPlacement?.processAsString(featureProperties, actualZoom) + ?.let { p -> p == "line" || p == "line-center" } ?: false + val iconViewportAligned = resolveViewportAligned( + layout.iconRotationAlignment?.processAsString(featureProperties, actualZoom), + defaultViewportAligned = !isLinePlacement + ) + Symbol.Sprite( + id = id, + global = Point(globalX, globalY), + placement = CompoundLabelPlacement(it, null), + value = sprite, + viewportAligned = iconViewportAligned, + ) + } + } + + /** + * Sets the angle of the caption to the range where the text always reads from left to right (or bottom to top for vertical lines). + * If the angle is outside [-90, 90] degrees, it is flipped 180°. + * This prevents the text from appearing upside down on the lines. + * @param angle the original angle (in degrees) + * @return the angle to display the text correctly + */ + private fun makeTextUpright(angle: Float): Float { + return if (angle > 90f || angle < -90f) angle + 180f else angle + } + + private fun produceSpriteWithText( + id: String, + placement: SymbolPlacement, + style: SymbolLayer, + featureProperties: EvalFeature?, + actualZoom: Double, + zoom: Double, + canvasSize: Int, + tileX: Int, + tileY: Int, + density: Density, + layerIndex: Int + ): Symbol? { + val textMeasurer = textMeasurerState.value ?: return null + + val layout = style.layout ?: return null + val paint = style.paint ?: return null + val spriteManager = spriteManager ?: return null + + val spriteId = + layout.iconImage?.processAsString(featureProperties, actualZoom)?.let { subProcess(it, featureProperties) } + ?: return null + val spriteInfo = spriteManager.getSpriteInfo(spriteId) ?: return null + + val iconScale: Float = layout.iconSize.processAsFloat(featureProperties, actualZoom) ?: DEFAULT_ICON_SCALE + val iconColor = paint.iconColor?.processAsColor(featureProperties, actualZoom) + val iconHaloColor = paint.iconHaloColor?.processAsColor(featureProperties, actualZoom) + val iconOpacity = paint.iconOpacity.processAsFloat(featureProperties, actualZoom) ?: 1f + if (iconOpacity == 0f) return null + + val scale = iconScale * density.density + + val sdf = if (spriteInfo.sdf) { + iconColor?.let { fillColor -> + SDF(fillColor = fillColor) + }?.let { + if (iconHaloColor != null) { + it.copy(haloColor = iconHaloColor) + } else it + } + } else null + + val spritePair = spriteManager.getSprite(spriteId, iconColor, sdf) ?: return null + val (spriteMeta, sprite) = spritePair + + val spriteSize = IntSize( + (spriteMeta.width.toFloat() * scale).toInt(), + (spriteMeta.height.toFloat() * scale).toInt() + ) + + // We receive the text + val templateTextField = layout.textField?.processAsString(featureProperties, actualZoom) ?: return null + val textField = substituteTemplate(templateTextField, featureProperties?.properties) + if (textField.isBlank() || textField.length > 256) return null + + val fontSize = (layout.textSize.processAsFloat(featureProperties, actualZoom) ?: 16f) + val textMaxWidth = + layout.textMaxWidth.processAsFloat(featureProperties, actualZoom) ?: Float.POSITIVE_INFINITY + + val textColor = paint.textColor?.processAsColor(featureProperties, actualZoom) ?: Color.Black + val textOpacity = paint.textOpacity.processAsFloat(featureProperties, actualZoom) ?: 1f + if (textOpacity == 0f) return null + + val textHaloColor = paint.textHaloColor?.processAsColor(featureProperties, actualZoom) ?: Color.White + val textHaloWidth = (paint.textHaloWidth.processAsFloat(featureProperties, actualZoom) ?: 0f) + + val iconOptional = layout.iconOptional?.processAsBoolean(featureProperties, actualZoom) ?: false + val textOptional = layout.textOptional?.processAsBoolean(featureProperties, actualZoom) ?: false + + val textStyle = TextStyle( + color = textColor.copy(alpha = textOpacity), + fontSize = fontSize.sp, + textAlign = TextAlign.Center, + shadow = if (textHaloWidth > 0f) { + Shadow( + color = textHaloColor, + offset = Offset.Zero, + blurRadius = textHaloWidth * 2 + ) + } else null + ) + + val textLayoutResult = textMeasurer.measure( + text = AnnotatedString(textField), + density = density, + style = textStyle, + maxLines = 5, + constraints = if (textMaxWidth.isFinite()) { + Constraints(maxWidth = (textMaxWidth * fontSize * density.density).toInt()) + } else { + Constraints() + }, + softWrap = true + ) + + val textSize = textLayoutResult.size + val verticalGap = 2.0f * density.density + + // We calculate the overall dimensions + val totalWidth = max(spriteSize.width, textSize.width) + val totalHeight = spriteSize.height + verticalGap + textSize.height + + // The sprite position remains in the center of placement + val spritePosition = placement.position + + // Normalized coordinates for the sprite's center + val normalizedPoint = tileCoordToNormalized( + tileX = tileX, + tileY = tileY, + pixelX = spritePosition.x.toDouble(), + pixelY = spritePosition.y.toDouble(), + zoom = zoom, + tileSize = canvasSize + ) + + val iconPadding = (layout.iconPadding.processAsFloat(featureProperties, actualZoom) ?: 2f) * density.density + val textPadding = (layout.textPadding.processAsFloat(featureProperties, actualZoom) ?: 2f) * density.density + + val iconRotateDeg = layout.iconRotate.processAsFloat(featureProperties, actualZoom) ?: 0f + val textRotateDeg = layout.textRotate.processAsFloat(featureProperties, actualZoom) ?: 0f + + val spriteLabelPlacement = LabelPlacement( + text = "sprite_$spriteId", + position = spritePosition, + angle = iconRotateDeg, + bounds = Rect( + left = spritePosition.x - spriteSize.width / 2f - iconPadding, + top = spritePosition.y - spriteSize.height / 2f - iconPadding, + right = spritePosition.x + spriteSize.width / 2f + iconPadding, + bottom = spritePosition.y + spriteSize.height / 2f + iconPadding + ), + obb = OBB( + spritePosition, + ovh.plrapps.mapcompose.vector.utils.obb.Size(spriteSize.width + 2 * iconPadding, spriteSize.height + 2 * iconPadding), + iconRotateDeg + ), + layerIndex = layerIndex, + inLayerPriority = layout.symbolSortKey.processAsDouble(featureProperties, actualZoom) ?: 0.0, + overlapMode = resolveIconOverlapMode(layout, featureProperties, actualZoom), + ignorePlacement = layout.iconIgnorePlacement?.processAsBoolean(featureProperties, actualZoom) ?: false + ) + + // Position of text under sprite + val textPosition = ObbPoint( + spritePosition.x, + spritePosition.y + spriteSize.height / 2f + verticalGap + textSize.height / 2f + ) + + // Create a LabelPlacement for the text + val textLabelPlacement = LabelPlacement( + text = textField, + position = textPosition, + angle = textRotateDeg, + bounds = Rect( + left = textPosition.x - textSize.width / 2f - textPadding, + top = textPosition.y - textSize.height / 2f - textPadding, + right = textPosition.x + textSize.width / 2f + textPadding, + bottom = textPosition.y + textSize.height / 2f + textPadding + ), + obb = OBB( + textPosition, + ovh.plrapps.mapcompose.vector.utils.obb.Size(textSize.width + 2 * textPadding, textSize.height + 2 * textPadding), + textRotateDeg + ), + layerIndex = layerIndex, + inLayerPriority = layout.symbolSortKey.processAsDouble(featureProperties, actualZoom) ?: 0.0, + overlapMode = resolveTextOverlapMode(layout, featureProperties, actualZoom), + ignorePlacement = layout.textIgnorePlacement?.processAsBoolean(featureProperties, actualZoom) ?: false + ) + + val variableAnchors = layout.textVariableAnchor?.processAsStringList(featureProperties, actualZoom) + val textAnchorStr = TextAnchor.fromAny(layout.textAnchor?.processAsString(featureProperties, actualZoom))?.value ?: "bottom" + val anchors: List = + if (!variableAnchors.isNullOrEmpty()) variableAnchors else listOf(textAnchorStr) + + val radialPx = (layout.textRadialOffset.processAsFloat(featureProperties, actualZoom) ?: 0f) * + fontSize * density.density + val hOff = spriteSize.width / 2f + verticalGap + radialPx + textSize.width / 2f + val vOff = spriteSize.height / 2f + verticalGap + radialPx + textSize.height / 2f + + + val textCandidates: List = anchors.map { anchor -> + val (dx, dy) = when (anchor) { + "top" -> Pair(0f, -vOff) + "bottom" -> Pair(0f, +vOff) + "left" -> Pair(-hOff, 0f) + "right" -> Pair(+hOff, 0f) + "top-left" -> Pair(-hOff, -vOff) + "top-right" -> Pair(+hOff, -vOff) + "bottom-left" -> Pair(-hOff, +vOff) + "bottom-right" -> Pair(+hOff, +vOff) + "center" -> Pair(0f, 0f) + else -> Pair(0f, +vOff) + } + val cx = spritePosition.x + dx + val cy = spritePosition.y + dy + val norm = tileCoordToNormalized(tileX, tileY, cx.toDouble(), cy.toDouble(), zoom, canvasSize) + TextPlacementCandidate( + labelPlacement = LabelPlacement( + text = textField, + position = ObbPoint(cx, cy), + angle = textRotateDeg, + bounds = Rect( + cx - textSize.width / 2f - textPadding, + cy - textSize.height / 2f - textPadding, + cx + textSize.width / 2f + textPadding, + cy + textSize.height / 2f + textPadding + ), + obb = OBB( + ObbPoint(cx, cy), + ovh.plrapps.mapcompose.vector.utils.obb.Size( + textSize.width + 2 * textPadding, + textSize.height + 2 * textPadding + ), + textRotateDeg + ), + layerIndex = layerIndex, + inLayerPriority = layout.symbolSortKey.processAsDouble(featureProperties, actualZoom) ?: 0.0, + overlapMode = resolveTextOverlapMode(layout, featureProperties, actualZoom), + ignorePlacement = layout.textIgnorePlacement?.processAsBoolean(featureProperties, actualZoom) ?: false + ), + mercatorX = norm.x, + mercatorY = norm.y, + dx = dx, + dy = dy, + ) + } + + val offsetY = -((spriteSize.height.toFloat() / 2f) / totalHeight) + + val centerOffset = Offset( + x = -0.5f, + y = offsetY + ) + + val viewportAligned = resolveViewportAligned( + layout.textRotationAlignment?.processAsString(featureProperties, actualZoom), + defaultViewportAligned = true // produceSpriteWithText is only called for point geometry + ) + + return Symbol.SpriteWithText( + id = id, + global = Point(normalizedPoint.x, normalizedPoint.y), + placement = CompoundLabelPlacement( + spritePlacement = spriteLabelPlacement, + textPlacement = textLabelPlacement + ), + align = centerOffset, + sprite = sprite, + text = textLayoutResult, + spriteSize = spriteSize, + textSize = IntSize(textSize.width, textSize.height), + verticalGap = verticalGap, + iconOptional = iconOptional, + textOptional = textOptional, + viewportAligned = viewportAligned, + textCandidates = textCandidates, + ) + } + + private suspend fun produceText( + placement: SymbolPlacement, + style: SymbolLayer, + featureProperties: EvalFeature?, + actualZoom: Double, + zoom: Double, + lineStrings: List>>? = null, + id: String, + canvasSize: Int, + tileX: Int, + tileY: Int, + density: Density, + layerIndex: Int + ): Symbol? { + val textMeasurer = textMeasurerState.value ?: return null + + val layout = style.layout ?: return null + val paint = style.paint ?: return null + + val templateTextField = layout.textField?.processAsString(featureProperties, actualZoom) ?: return null + val textField = substituteTemplate(templateTextField, featureProperties?.properties) + if (textField.isBlank() || textField.length > 256) { + return null + } + + val fontSize = (layout.textSize.processAsFloat(featureProperties, actualZoom) ?: 16f) + val textMaxWidth = + layout.textMaxWidth.processAsFloat(featureProperties, actualZoom) ?: Float.POSITIVE_INFINITY + + val textColor = paint.textColor?.processAsColor(featureProperties, actualZoom) ?: Color.Black + val textOpacity = paint.textOpacity.processAsFloat(featureProperties, actualZoom) ?: 1f + if (textOpacity == 0f) { + return null + } + + val textHaloColor = paint.textHaloColor?.processAsColor(featureProperties, actualZoom) ?: Color.White + val textHaloWidth = (paint.textHaloWidth.processAsFloat(featureProperties, actualZoom) ?: 0f) + val offsetArr = layout.textOffset.processAsDoubleList(featureProperties, actualZoom) ?: listOf(0.0, 0.0) + val anchor = TextAnchor.fromAny(layout.textAnchor?.processAsString(featureProperties, actualZoom)) ?: TextAnchor.Center + + val dx = (offsetArr.getOrNull(0) ?: 0.0).toFloat() * fontSize + val dy = (offsetArr.getOrNull(1) ?: 0.0).toFloat() * fontSize + + val textStyle = TextStyle( + color = textColor.copy(alpha = textOpacity), + fontSize = fontSize.sp, + textAlign = TextAlign.Unspecified, + shadow = if (textHaloWidth > 0f) { + Shadow( + color = textHaloColor, + offset = Offset.Zero, + blurRadius = textHaloWidth * 2 + ) + } else null + ) + + val textCacheKey = "TEXT-$textField-${fontSize}-${textColor.toArgb()}-${textHaloWidth}-${textHaloColor.toArgb()}-${density.density}" + val textLayoutResult = mutex.withLock { + pathCache.get(textCacheKey) as? TextLayoutResult + } ?: textMeasurer.measure( + text = AnnotatedString(textField), + density = density, + style = textStyle, + maxLines = 1, + constraints = Constraints(), + softWrap = true + ).also { + mutex.withLock { + pathCache.put(textCacheKey, it) + } + } + + val textWidth = textLayoutResult.size.width.toFloat() + val textHeight = textLayoutResult.size.height.toFloat() + val verticalGap = 1.1f * density.density + + if (lineStrings != null && lineStrings.isNotEmpty()) { + return produceLineText( + id = id, + lineStrings = lineStrings, + layout = layout, + featureProperties = featureProperties, + actualZoom = actualZoom, + textWidth = textWidth, + dx = dx, + dy = dy, + tileX = tileX, + tileY = tileY, + textHeight = textHeight, + textField = textField, + zoom = zoom, + canvasSize = canvasSize, + textLayoutResult = textLayoutResult, + density = density, + layerIndex = layerIndex + ) + } else { + return producePointText( + id = id, + placement = placement, + anchor = anchor, + textWidth = textWidth, + textHeight = textHeight, + dx = dx, + dy = dy, + tileX = tileX, + tileY = tileY, + zoom = zoom, + canvasSize = canvasSize, + layout = layout, + featureProperties = featureProperties, + actualZoom = actualZoom, + textLayoutResult = textLayoutResult, + textField = textField, + density = density, + layerIndex = layerIndex + ) + } + } + + private fun produceLineText( + id: String, + lineStrings: List>>, + layout: SymbolLayout, + featureProperties: EvalFeature?, + actualZoom: Double, + textWidth: Float, + dx: Float, + dy: Float, + tileX: Int, + tileY: Int, + textHeight: Float, + textField: String, + zoom: Double, + canvasSize: Int, + textLayoutResult: TextLayoutResult, + density: Density, + layerIndex: Int, + ): Symbol? { + val symbolSpacing = layout.symbolSpacing.processAsFloat(featureProperties, actualZoom) ?: 250f + val maxAngleDeg = layout.textMaxAngle.processAsFloat(featureProperties, actualZoom) ?: 45f + val textRotateDeg = layout.textRotate.processAsFloat(featureProperties, actualZoom) ?: 0f + val textViewportAligned = resolveViewportAligned( + layout.textRotationAlignment?.processAsString(featureProperties, actualZoom), + defaultViewportAligned = false // "auto" + line placement = map-aligned + ) + val textPadding = (layout.textPadding.processAsFloat(featureProperties, actualZoom) ?: 2f) * density.density + var anyPlaced = false + var maxLength = 0f + var maxLine: List>? = null + + lineStrings.forEachIndexed lineStrings@{ indexLine, line -> + if (line.size < 2) return@lineStrings + val lineLength = line.zipWithNext { a, b -> + val dx = a.first - b.first + val dy = a.second - b.second + sqrt(dx * dx + dy * dy) + }.sum() + + if (lineLength > maxLength) { + maxLength = lineLength + maxLine = line + } + + if (lineLength < textWidth) return@lineStrings + val placements = LineLabelPlacement.calculatePlacements(line, textWidth, symbolSpacing, maxAngleDeg) + if (placements.isNotEmpty()) anyPlaced = true + placements.forEachIndexed { index, (pos, angle) -> + val distToStart = + sqrt((pos.first - line.first().first).pow(2) + (pos.second - line.first().second).pow(2)) + val distToEnd = + sqrt((pos.first - line.last().first).pow(2) + (pos.second - line.last().second).pow(2)) + if (distToStart < textWidth / 2 || distToEnd < textWidth / 2) return@forEachIndexed + val x = pos.first + dx + val y = pos.second + dy + val displayAngle = makeTextUpright(angle) + + try { + val normalizedPoint = + tileCoordToNormalized(tileX, tileY, x.toDouble(), y.toDouble(), zoom, canvasSize) + val globalX = normalizedPoint.x + val globalY = normalizedPoint.y + + val leftBound = (x.toDouble() - textWidth.toDouble() / 2.0).toFloat() - textPadding + val topBound = (y.toDouble() - textHeight.toDouble() / 2.0).toFloat() - textPadding + val rightBound = (x.toDouble() + textWidth.toDouble() / 2.0).toFloat() + textPadding + val bottomBound = (y.toDouble() + textHeight.toDouble() / 2.0).toFloat() + textPadding + + // Create a deterministic ID based on a tile, coordinates and indices + val coordHash = "${x.toInt()}_${y.toInt()}_${displayAngle.toInt()}" + val stableId = "L${tileX}_${tileY}_${id}_${indexLine}_${index}_$coordHash" + + val lineTextAngle = displayAngle + textRotateDeg + return LabelPlacement( + text = textField, + position = ObbPoint(x, y), + angle = lineTextAngle, + bounds = Rect( + left = leftBound, + top = topBound, + right = rightBound, + bottom = bottomBound + ), + obb = OBB( + ObbPoint(x, y), + ovh.plrapps.mapcompose.vector.utils.obb.Size(textWidth + 2 * textPadding, textHeight + 2 * textPadding), + lineTextAngle + ), + layerIndex = layerIndex, + inLayerPriority = layout.symbolSortKey.processAsDouble(featureProperties, actualZoom) ?: 0.0, + overlapMode = resolveTextOverlapMode(layout, featureProperties, actualZoom), + ignorePlacement = layout.textIgnorePlacement?.processAsBoolean(featureProperties, actualZoom) + ?: false + ).let { labelPlacement -> + Symbol.Text( + id = stableId, + global = Point(globalX, globalY), + placement = CompoundLabelPlacement( + spritePlacement = labelPlacement, + textPlacement = labelPlacement + ), + value = textLayoutResult, + viewportAligned = textViewportAligned, + ) + } + } catch (_: IllegalArgumentException) { + return@forEachIndexed + } + } + } + + if (!anyPlaced && maxLine != null && maxLine.size >= 2) { + val maxLineRef = maxLine // for smart cast + val maxLineLength = maxLineRef.zipWithNext { a, b -> + val dx = a.first - b.first + val dy = a.second - b.second + sqrt(dx * dx + dy * dy) + }.sum() + + if (maxLineLength >= textWidth) { + val midIdx = maxLineRef.size / 2 + val p1 = maxLineRef[midIdx - 1] + val p2 = maxLineRef[midIdx] + val x = (p1.first + p2.first) / 2 + dx + val y = (p1.second + p2.second) / 2 + dy + val angle = atan2(p2.second - p1.second, p2.first - p1.first) * 180f / PI.toFloat() + val displayAngle = makeTextUpright(angle) + val distToStart = sqrt((x - maxLineRef.first().first).pow(2) + (y - maxLineRef.first().second).pow(2)) + val distToEnd = sqrt((x - maxLineRef.last().first).pow(2) + (y - maxLineRef.last().second).pow(2)) + if (distToStart >= textWidth / 2 && distToEnd >= textWidth / 2) { + try { + val normalizedPoint = + tileCoordToNormalized(tileX, tileY, x.toDouble(), y.toDouble(), zoom, canvasSize) + val globalX = normalizedPoint.x + val globalY = normalizedPoint.y + + val leftBound = (x.toDouble() - textWidth.toDouble() / 2.0).toFloat() - textPadding + val topBound = (y.toDouble() - textHeight.toDouble() / 2.0).toFloat() - textPadding + val rightBound = (x.toDouble() + textWidth.toDouble() / 2.0).toFloat() + textPadding + val bottomBound = (y.toDouble() + textHeight.toDouble() / 2.0).toFloat() + textPadding + val lineTextAngle = displayAngle + textRotateDeg + return LabelPlacement( + text = textField, + position = ObbPoint(x, y), + angle = lineTextAngle, + bounds = Rect( + left = leftBound, + top = topBound, + right = rightBound, + bottom = bottomBound + ), + obb = OBB( + ObbPoint(x, y), + ovh.plrapps.mapcompose.vector.utils.obb.Size(textWidth + 2 * textPadding, textHeight + 2 * textPadding), + lineTextAngle + ), + layerIndex = layerIndex, + inLayerPriority = layout.symbolSortKey.processAsDouble(featureProperties, actualZoom) ?: 0.0, + overlapMode = resolveTextOverlapMode(layout, featureProperties, actualZoom), + ignorePlacement = layout.textIgnorePlacement?.processAsBoolean(featureProperties, actualZoom) + ?: false + ).let { labelPlacement -> + // Create a deterministic ID for the fallback case + val coordHash = "${x.toInt()}_${y.toInt()}_${displayAngle.toInt()}" + val stableId = "LF${tileX}_${tileY}_${id}_$coordHash" + + Symbol.Text( + id = stableId, + global = Point(globalX, globalY), + placement = CompoundLabelPlacement( + spritePlacement = labelPlacement, + textPlacement = labelPlacement + ), + value = textLayoutResult, + viewportAligned = textViewportAligned, + ) + } + } catch (_: IllegalArgumentException) { + return null + } + } + } + return null + } + return null + } + + private fun producePointText( + placement: SymbolPlacement, + anchor: TextAnchor, + textWidth: Float, + textHeight: Float, + dx: Float, + dy: Float, + tileX: Int, + tileY: Int, + zoom: Double, + canvasSize: Int, + layout: SymbolLayout, + featureProperties: EvalFeature?, + actualZoom: Double, + textLayoutResult: TextLayoutResult, + textField: String, + density: Density, + id: String, + layerIndex: Int + ): Symbol? { + val x = placement.position.x + dx + val y = placement.position.y + dy + val textPosition = when (anchor) { + TextAnchor.Top -> ObbPoint(x, y - textHeight / 2) + TextAnchor.Bottom -> ObbPoint(x, y + textHeight / 2) + TextAnchor.Left -> ObbPoint(x - textWidth / 2, y) + TextAnchor.Right -> ObbPoint(x + textWidth / 2, y) + TextAnchor.TopLeft -> ObbPoint(x - textWidth / 2, y - textHeight / 2) + TextAnchor.TopRight -> ObbPoint(x + textWidth / 2, y - textHeight / 2) + TextAnchor.BottomLeft -> ObbPoint(x - textWidth / 2, y + textHeight / 2) + TextAnchor.BottomRight -> ObbPoint(x + textWidth / 2, y + textHeight / 2) + TextAnchor.Center -> ObbPoint(x, y) + } + + try { + val normalizedPoint = tileCoordToNormalized( + tileX = tileX, + tileY = tileY, + pixelX = textPosition.x.toDouble(), + pixelY = textPosition.y.toDouble(), + zoom = zoom, + tileSize = canvasSize + ) + val globalX = normalizedPoint.x + val globalY = normalizedPoint.y + + val textPadding = (layout.textPadding.processAsFloat(featureProperties, actualZoom) ?: 2f) * density.density + + val leftBound = (textPosition.x.toDouble() - textWidth.toDouble() / 2.0).toFloat() - textPadding + val topBound = (textPosition.y.toDouble() - textHeight.toDouble() / 2.0).toFloat() - textPadding + val rightBound = (textPosition.x.toDouble() + textWidth.toDouble() / 2.0).toFloat() + textPadding + val bottomBound = (textPosition.y.toDouble() + textHeight.toDouble() / 2.0).toFloat() + textPadding + + val bounds = Rect( + left = leftBound, + top = topBound, + right = rightBound, + bottom = bottomBound + ) + + val textRotateDeg = layout.textRotate.processAsFloat(featureProperties, actualZoom) ?: 0f + val pointTextAngle = placement.angle + textRotateDeg + return LabelPlacement( + text = textField, + position = ObbPoint(textPosition.x, textPosition.y), + angle = pointTextAngle, + bounds = bounds, + obb = OBB( + center = ObbPoint(textPosition.x, textPosition.y), + size = ovh.plrapps.mapcompose.vector.utils.obb.Size(textWidth + 2 * textPadding, textHeight + 2 * textPadding), + rotation = pointTextAngle + ), + layerIndex = layerIndex, + inLayerPriority = layout.symbolSortKey.processAsDouble(featureProperties, actualZoom) ?: 0.0, + overlapMode = resolveTextOverlapMode(layout, featureProperties, actualZoom), + ignorePlacement = layout.textIgnorePlacement?.processAsBoolean(featureProperties, actualZoom) ?: false + ).let { labelPlacement -> + // Add coordinates to ID for uniqueness + val coordHash = "${textPosition.x.toInt()}_${textPosition.y.toInt()}" + val stableId = "P${tileX}_${tileY}_${id}_$coordHash" + + val textViewportAligned = resolveViewportAligned( + layout.textRotationAlignment?.processAsString(featureProperties, actualZoom), + defaultViewportAligned = true + ) + Symbol.Text( + id = stableId, + global = Point(globalX, globalY), + placement = CompoundLabelPlacement( + spritePlacement = labelPlacement, + textPlacement = labelPlacement // Correct placement for text + ), + value = textLayoutResult, + viewportAligned = textViewportAligned, + ) + } + } catch (_: IllegalArgumentException) { + return null + } + } + + suspend fun produceSymbol( + feature: Tile.Feature, + style: SymbolLayer, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: EvalFeature?, + actualZoom: Double, + id: String, + tileX: Int = 0, + tileY: Int = 0, + density: Density, + layerIndex: Int = 0, + ): List { + val layout = style.layout ?: return emptyList() + val paint = style.paint ?: return emptyList() + + // Calculate the symbol placement + val placement: SymbolPlacement? + var lineStrings: List>>? = null + if (feature.type == Tile.GeomType.POINT) { + placement = calculatePointPlacement(feature = feature, extent = extent, canvasSize = canvasSize) + } else if (layout.symbolPlacement?.processAsString(featureProperties, actualZoom) == "line" && + feature.type == Tile.GeomType.LINESTRING + ) { + lineStrings = geometryDecoders.decodeLine( + geometry = feature.geometry, + extent = extent, + canvasSize = canvasSize + ) + placement = lineStrings.firstOrNull()?.firstOrNull()?.let { + SymbolPlacement( + position = ObbPoint(it.first, it.second), + angle = 0f + ) + } + } else { + placement = null + } + if (placement == null) return emptyList() + + // Check if there is both a sprite and text + val hasSprite = layout.iconImage != null && spriteManager != null + val hasText = layout.textField != null + + if (hasSprite && hasText && feature.type == Tile.GeomType.POINT && lineStrings == null) { + // Create a SpriteWithText combo symbol for point objects + produceSpriteWithText( + id = "ST${tileX}_${tileY}_${id}", + placement = placement, + style = style, + featureProperties = featureProperties, + actualZoom = actualZoom, + zoom = zoom, + canvasSize = canvasSize, + tileX = tileX, + tileY = tileY, + density = density, + layerIndex = layerIndex + )?.let { return listOf(it) } + } + + // For lines or when there is only one element - create separate symbols + val list = mutableListOf() + + if (hasSprite) { + produceSprite( + id = "S${tileX}_${tileY}_${id}", + placement = placement, + style = style, + featureProperties = featureProperties, + actualZoom = actualZoom, + zoom = zoom, + canvasSize = canvasSize, + tileX = tileX, + tileY = tileY, + density = density, + layerIndex = layerIndex + )?.let { list.add(it) } + } + + if (hasText) { + produceText( + id = "T${tileX}_${tileY}_${id}", + placement = placement, + style = style, + featureProperties = featureProperties, + actualZoom = actualZoom, + zoom = zoom, + lineStrings = lineStrings, + canvasSize = canvasSize, + tileX = tileX, + tileY = tileY, + density = density, + layerIndex = layerIndex + )?.let { list.add(it) } + } + + return list + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/SymbolsProducer.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/SymbolsProducer.kt new file mode 100644 index 00000000..bd0d9491 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/SymbolsProducer.kt @@ -0,0 +1,95 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.unit.Density +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import ovh.plrapps.mapcompose.vector.data.MapLibreConfiguration +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.SymbolLayer +import ovh.plrapps.mapcompose.vector.utils.LruCache + +class SymbolsProducer( + configuration: MapLibreConfiguration, + private val textMeasurer: MutableStateFlow, + private val pathCache: LruCache, + private val pathCacheMutex: Mutex +) : BaseRenderer(configuration = configuration) { + + val symbolsPainter = SymbolLayerPainter( + textMeasurerState = textMeasurer, + spriteManager = configuration.spriteManager, + configuration = configuration, + pathCache = pathCache, + mutex = pathCacheMutex + ) + + suspend fun produce( + tile: Tile?, + styleLayer: SymbolLayer, + layerIndex: Int = 0, + zoom: Double, + canvasSize: Int, + actualZoom: Double, + tileX: Int = 0, + tileY: Int = 0, + density: Density, + localPropCache: MutableMap + ): List { + if (!isZoomInRange(styleLayer, zoom)) { +// println(" missed by zoom") + return emptyList() + } + + if (tile == null || tile.layers.isEmpty()) return emptyList() + + val tileLayer = tile.layers.find { it.name == styleLayer.sourceLayer } + + if (tileLayer == null) { + return emptyList() + } + + // The sprite and the text of the sprite MAY be in different features, but in the same tile, + // because their points match. Therefore, this is one element, but in what order they were drawn, + // we do not know. Therefore, if the points match, then the text should be placed under the sprite, and if it is a sprite, then draw it above the text + val symbols = mutableListOf() + + // Feature geometry is only decoded when a `within`/`distance` expression reads it. + val needGeometry = styleLayer.filter?.filter?.needGeometry == true + + for (feature in tileLayer.features) { + val featureIdKey = feature.id?.toString() ?: feature.hashCode().toString() + val propertyKey = if (tileX != 0 || tileY != 0) "T-$tileX-$tileY-${tileLayer.name}-$featureIdKey" else null + val featureProperties = if (propertyKey != null) { + localPropCache.getOrPut(propertyKey) { buildEvalFeature(feature, tileLayer, needGeometry) } + } else { + buildEvalFeature(feature, tileLayer, needGeometry) + } + + val isShouldRenderFeature = shouldRenderFeature(feature, tileLayer, styleLayer, zoom, featureProperties) + if (!isShouldRenderFeature) continue + + val extent = tileLayer.extent ?: 4096 + + symbolsPainter.produceSymbol( + id = feature.id?.toString() ?: "unknown_${feature.hashCode()}", + feature = feature, + style = styleLayer, + canvasSize = canvasSize, + extent = extent, + zoom = zoom, + featureProperties = featureProperties, + actualZoom = actualZoom, + tileX = tileX, + tileY = tileY, + density = density, + layerIndex = layerIndex + ).let { symbol-> + symbols.addAll(symbol) + } + } + return symbols + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/TileRenderer.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/TileRenderer.kt new file mode 100644 index 00000000..b47d4fa4 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/TileRenderer.kt @@ -0,0 +1,211 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature + +import androidx.compose.ui.graphics.drawscope.DrawScope +import ovh.plrapps.mapcompose.vector.data.MapLibreConfiguration +import ovh.plrapps.mapcompose.vector.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.* +import ovh.plrapps.mapcompose.vector.utils.LruCache +import kotlinx.coroutines.sync.Mutex + +class TileRenderer( + configuration: MapLibreConfiguration, + private val pathCache: LruCache, + private val pathCacheMutex: Mutex, + private val localPropCache: MutableMap +) : BaseRenderer(configuration = configuration) { + private val painters = mutableMapOf>() + + suspend fun render( + canvas: DrawScope, + tile: Tile?, + styleLayer: Layer, + zoom: Double, + canvasSize: Int, + actualZoom: Double, + tileKey: String? = null + ) { + if (!isZoomInRange(styleLayer, zoom)) { + // println(" missed by zoom") + return + } + when (styleLayer) { + is SymbolLayer -> {return} + is BackgroundLayer -> { + painters + .getOrPut(styleLayer) { + BackgroundLayerPainter() + }.let { + it as BackgroundLayerPainter + } + .paint( + canvas = canvas, + feature = Tile.Feature( + id = -1, + type = Tile.GeomType.POINT, + geometry = emptyList(), + tags = emptyList() + ), + style = styleLayer, + canvasSize = canvasSize, + extent = 4096, + zoom = zoom, + featureProperties = null, + actualZoom = actualZoom, + ) + } + is CircleLayer, + is FillLayer, + is LineLayer, + is SymbolLayer, + is FillExtrusionLayer, + is HeatmapLayer, + is RasterLayer, + is SkyLayer, + is HillshadeLayer -> { + if (tile == null || tile.layers.isEmpty()) return + val tileLayer = tile.layers.find { it.name == styleLayer.sourceLayer } + if (tileLayer == null) { +// println("source-layer '${styleLayer.sourceLayer}' not found") + return + } + val extent = tileLayer.extent ?: 4096 + val painter = painters + .getOrPut(styleLayer) { + when (styleLayer) { + is SymbolLayer, + is BackgroundLayer -> throw IllegalStateException("BackgroundLayer cant be here") + is CircleLayer -> CircleLayerPainter() + is FillExtrusionLayer -> FillExtrusionPainter() + is FillLayer -> FillLayerPainter(pathCache, pathCacheMutex) + is HeatmapLayer -> HeatmapLayerPainter() + is HillshadeLayer -> HillshadeLayerPainter() + is LineLayer -> LineLayerPainter(pathCache, pathCacheMutex) + is RasterLayer -> RasterLayerPainter() + is SkyLayer -> SkyLayerPainter() + } + } + + // Feature geometry is only decoded when a `within`/`distance` expression reads it. + val needGeometry = styleLayer.filter?.filter?.needGeometry == true + + for (feature in tileLayer.features) { + val featureIdKey = feature.id?.toString() ?: feature.hashCode().toString() + val propertyKey = if (tileKey != null) "$tileKey-${tileLayer.name}-$featureIdKey" else null + val featureProperties = if (propertyKey != null) { + localPropCache.getOrPut(propertyKey) { buildEvalFeature(feature, tileLayer, needGeometry) } + } else { + buildEvalFeature(feature, tileLayer, needGeometry) + } + + val isShouldRenderFeature = shouldRenderFeature(feature, tileLayer, styleLayer, zoom, featureProperties) + if (!isShouldRenderFeature) continue + + val featureKey = if (tileKey != null) "$tileKey-${styleLayer.id}-$featureIdKey" else null + + when (styleLayer) { + is CircleLayer -> (painter as CircleLayerPainter).paint( + canvas = canvas, + feature = feature, + style = styleLayer, + canvasSize = canvasSize, + extent = extent, + zoom = zoom, + featureProperties = featureProperties, + actualZoom = actualZoom, + featureKey = featureKey + ) + + is FillLayer -> (painter as FillLayerPainter).paint( + canvas = canvas, + feature = feature, + style = styleLayer, + canvasSize = canvasSize, + extent = extent, + zoom = zoom, + featureProperties = featureProperties, + actualZoom = actualZoom, + featureKey = featureKey + ) + + is LineLayer -> (painter as LineLayerPainter).paint( + canvas = canvas, + feature = feature, + style = styleLayer, + canvasSize = canvasSize, + extent = extent, + zoom = zoom, + featureProperties = featureProperties, + actualZoom = actualZoom, + featureKey = featureKey + ) + + is SymbolLayer -> { /*do nothing; render happened in separate place*/} + is BackgroundLayer -> throw IllegalStateException("BackgroundLayer cant be here") + is FillExtrusionLayer -> (painter as FillExtrusionPainter).paint( + canvas = canvas, + feature = feature, + style = styleLayer, + canvasSize = canvasSize, + extent = extent, + zoom = zoom, + featureProperties = featureProperties, + actualZoom = actualZoom, + featureKey = featureKey + ) + + is HeatmapLayer -> (painter as HeatmapLayerPainter).paint( + canvas = canvas, + feature = feature, + style = styleLayer, + canvasSize = canvasSize, + extent = extent, + zoom = zoom, + featureProperties = featureProperties, + actualZoom = actualZoom, + featureKey = featureKey + ) + + is HillshadeLayer -> (painter as HillshadeLayerPainter).paint( + canvas = canvas, + feature = feature, + style = styleLayer, + canvasSize = canvasSize, + extent = extent, + zoom = zoom, + featureProperties = featureProperties, + actualZoom = actualZoom, + featureKey = featureKey + ) + + is RasterLayer -> (painter as RasterLayerPainter).paint( + canvas = canvas, + feature = feature, + style = styleLayer, + canvasSize = canvasSize, + extent = extent, + zoom = zoom, + featureProperties = featureProperties, + actualZoom = actualZoom, + featureKey = featureKey + ) + + is SkyLayer -> (painter as SkyLayerPainter).paint( + canvas = canvas, + feature = feature, + style = styleLayer, + canvasSize = canvasSize, + extent = extent, + zoom = zoom, + featureProperties = featureProperties, + actualZoom = actualZoom, + featureKey = featureKey + ) + } + } + } + } + } +} + diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/CollisionDetector.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/CollisionDetector.kt new file mode 100644 index 00000000..bee34abf --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/CollisionDetector.kt @@ -0,0 +1,53 @@ +package ovh.plrapps.mapcompose.vector.renderer.collision + +import androidx.compose.ui.geometry.Rect +import ovh.plrapps.mapcompose.vector.utils.rtree.Rtree + +class CollisionDetector { + private val rtree = Rtree() + + fun wouldCollide(label: LabelPlacement): Boolean { + if (label.ignorePlacement) return false + if (label.overlapMode == OverlapMode.Always) return false + + val candidates = rtree.search(label.obb.getAABB()) + return candidates.any { existing -> + !existing.ignorePlacement && + label.obb.intersects(existing.obb) && + when (label.overlapMode) { + // Never: blocked by any non-ignore symbol + OverlapMode.Never -> true + // Cooperative: blocked only by Never symbols; can overlap other Cooperative/Always + OverlapMode.Cooperative -> existing.overlapMode == OverlapMode.Never + // Always: never blocked (unreachable, handled above) + OverlapMode.Always -> false + } + } + } + + fun tryPlaceLabel(label: LabelPlacement): Boolean { + if (label.ignorePlacement) return true + if (wouldCollide(label)) return false + // Insert everything except ignore-placement labels. + // Even Always/Cooperative labels are inserted so they can block Never symbols placed later. + rtree.insert(label.obb.getAABB(), label) + return true + } + + fun insert(label: LabelPlacement) { + if (!label.ignorePlacement) { + rtree.insert(label.obb.getAABB(), label) + } + } + + fun clear() { + rtree.clear() + } +} + +fun Rect.intersects(other: Rect): Boolean { + return this.left < other.right && + this.right > other.left && + this.top < other.bottom && + this.bottom > other.top +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/LabelPlacement.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/LabelPlacement.kt new file mode 100644 index 00000000..1d3309de --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/LabelPlacement.kt @@ -0,0 +1,17 @@ +package ovh.plrapps.mapcompose.vector.renderer.collision + +import androidx.compose.ui.geometry.Rect +import ovh.plrapps.mapcompose.vector.utils.obb.OBB +import ovh.plrapps.mapcompose.vector.utils.obb.ObbPoint + +data class LabelPlacement( + val text: String, + val position: ObbPoint, + val angle: Float, + val bounds: Rect, + val obb: OBB, + val layerIndex: Int, + val inLayerPriority: Double, + val overlapMode: OverlapMode, + val ignorePlacement: Boolean, +) diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/LineLabelPlacement.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/LineLabelPlacement.kt new file mode 100644 index 00000000..79c3ecd5 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/LineLabelPlacement.kt @@ -0,0 +1,78 @@ +package ovh.plrapps.mapcompose.vector.renderer.collision + +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.sqrt + +class LineLabelPlacement { + companion object { + fun calculatePlacements( + points: List>, + textWidth: Float, + spacing: Float, + maxAngleDeg: Float = 45f + ): List, Float>> { + if (points.size < 2) return emptyList() + + val n = points.size + + // Precompute per-segment lengths, angles, and cumulative distance at each point. + val segLen = FloatArray(n - 1) + val segAngle = FloatArray(n - 1) + val cumDist = FloatArray(n) + cumDist[0] = 0f + + for (i in 0 until n - 1) { + val dx = points[i + 1].first - points[i].first + val dy = points[i + 1].second - points[i].second + val len = sqrt(dx * dx + dy * dy) + segLen[i] = len + segAngle[i] = if (len > 0f) atan2(dy, dx) * 180f / PI.toFloat() + else if (i > 0) segAngle[i - 1] else 0f + cumDist[i + 1] = cumDist[i] + len + } + + val placements = mutableListOf, Float>>() + + for (i in 0 until n - 1) { + val len = segLen[i] + if (len == 0f) continue + + val p1 = points[i] + val dx = points[i + 1].first - p1.first + val dy = points[i + 1].second - p1.second + val angle = segAngle[i] + + var localDist = spacing / 2f + while (localDist + textWidth / 2f <= len) { + val globalPos = cumDist[i] + localDist + val spanStart = globalPos - textWidth / 2f + val spanEnd = globalPos + textWidth / 2f + + // Reject if any interior corner within the label span is too sharp. + var tooSharp = false + for (j in 1 until n - 1) { + val cd = cumDist[j] + if (cd <= spanStart) continue + if (cd >= spanEnd) break + var delta = abs(segAngle[j] - segAngle[j - 1]) + if (delta > 180f) delta = 360f - delta + if (delta > maxAngleDeg) { tooSharp = true; break } + } + + if (!tooSharp) { + val t = localDist / len + placements.add( + (p1.first + t * dx to p1.second + t * dy) to angle + ) + } + + localDist += spacing + } + } + + return placements + } + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/OverlapMode.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/OverlapMode.kt new file mode 100644 index 00000000..53c66821 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/OverlapMode.kt @@ -0,0 +1,21 @@ +package ovh.plrapps.mapcompose.vector.renderer.collision + +/** + * Controls whether a symbol can overlap other symbols. + * + * Mirrors the MapLibre `icon-overlap` / `text-overlap` spec values. + * The older `icon-allow-overlap` / `text-allow-overlap` booleans map to [Never] (false) / [Always] (true). + */ +enum class OverlapMode { + /** Symbol is hidden when it collides with any other visible symbol. (default) */ + Never, + + /** Symbol is always visible regardless of collisions. Blocks [Never] symbols placed later. */ + Always, + + /** + * Symbol is visible unless it collides with a [Never] symbol. + * Two [Cooperative] symbols may overlap each other freely. + */ + Cooperative, +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/utils/MVTViewport.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/utils/MVTViewport.kt new file mode 100644 index 00000000..212b6dc7 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/utils/MVTViewport.kt @@ -0,0 +1,15 @@ +package ovh.plrapps.mapcompose.vector.renderer.utils + +import androidx.compose.ui.geometry.Rect + +data class MVTViewport( + val width: Float, + val height: Float, + val bearing: Float, + val pitch: Float, + val zoom: Float, + val tileMatrix: Map +) + +val MVTViewport.bbox + get() = Rect(left = 0f, top = 0f, right = width, bottom = height) \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/layout.json b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/layout.json new file mode 100644 index 00000000..7b3321d0 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/layout.json @@ -0,0 +1,240 @@ +{ + "line": { + "groups": [ + { + "title": "Paint properties", + "type": "properties", + "fields": [ + "line-opacity", + "line-color", + "line-width", + "line-offset", + "line-blur", + "line-dasharray", + "line-pattern", + "line-translate", + "line-translate-anchor", + "line-gap-width" + ] + }, + { + "title": "Layout properties", + "type": "properties", + "fields": [ + "line-cap", + "line-join", + "line-miter-limit", + "line-round-limit" + ] + } + ] + }, + "background": { + "groups": [ + { + "title": "Paint properties", + "type": "properties", + "fields": [ + "background-color", + "background-pattern", + "background-opacity" + ] + } + ] + }, + "fill": { + "groups": [ + { + "title": "Paint properties", + "type": "properties", + "fields": [ + "fill-opacity", + "fill-color", + "fill-antialias", + "fill-outline-color", + "fill-pattern", + "fill-translate", + "fill-translate-anchor" + ] + } + ] + }, + "fill-extrusion": { + "groups": [ + { + "title": "Paint properties", + "type": "properties", + "fields": [ + "fill-extrusion-opacity", + "fill-extrusion-color", + "fill-extrusion-translate", + "fill-extrusion-translate-anchor", + "fill-extrusion-pattern", + "fill-extrusion-height", + "fill-extrusion-base", + "fill-extrusion-vertical-gradient" + ] + } + ] + }, + "circle": { + "groups": [ + { + "title": "Paint properties", + "type": "properties", + "fields": [ + "circle-color", + "circle-opacity", + "circle-stroke-color", + "circle-stroke-opacity", + "circle-blur", + "circle-radius", + "circle-stroke-width", + "circle-pitch-scale", + "circle-translate", + "circle-translate-anchor", + "circle-pitch-alignment" + ] + } + ] + }, + "symbol": { + "groups": [ + { + "title": "General layout properties", + "type": "properties", + "fields": [ + "symbol-placement", + "symbol-spacing", + "symbol-avoid-edges", + "symbol-z-order" + ] + }, + { + "title": "Text layout properties", + "type": "properties", + "fields": [ + "text-field", + "text-font", + "text-size", + "text-line-height", + "text-padding", + "text-allow-overlap", + "text-ignore-placement", + "text-pitch-alignment", + "text-rotation-alignment", + "text-max-width", + "text-letter-spacing", + "text-justify", + "text-anchor", + "text-max-angle", + "text-writing-mode", + "text-rotate", + "text-keep-upright", + "text-transform", + "text-offset", + "text-optional", + "text-variable-anchor", + "text-radial-offset" + ] + }, + { + "title": "Icon layout properties", + "type": "properties", + "fields": [ + "icon-image", + "icon-allow-overlap", + "icon-ignore-placement", + "icon-optional", + "icon-rotation-alignment", + "icon-size", + "icon-text-fit", + "icon-text-fit-padding", + "icon-rotate", + "icon-padding", + "icon-keep-upright", + "icon-offset", + "icon-anchor", + "icon-pitch-alignment" + ] + }, + { + "title": "Text paint properties", + "type": "properties", + "fields": [ + "text-color", + "text-opacity", + "text-halo-color", + "text-halo-width", + "text-halo-blur", + "text-translate", + "text-translate-anchor" + ] + }, + { + "title": "Icon paint properties", + "type": "properties", + "fields": [ + "icon-color", + "icon-opacity", + "icon-halo-color", + "icon-halo-width", + "icon-halo-blur", + "icon-translate", + "icon-translate-anchor" + ] + } + ] + }, + "raster": { + "groups": [ + { + "title": "Paint properties", + "type": "properties", + "fields": [ + "raster-opacity", + "raster-hue-rotate", + "raster-brightness-min", + "raster-brightness-max", + "raster-saturation", + "raster-contrast", + "raster-fade-duration", + "raster-resampling" + ] + } + ] + }, + "hillshade": { + "groups": [ + { + "title": "Paint properties", + "type": "properties", + "fields": [ + "hillshade-illumination-direction", + "hillshade-illumination-anchor", + "hillshade-exaggeration", + "hillshade-shadow-color", + "hillshade-highlight-color", + "hillshade-accent-color" + ] + } + ] + }, + "heatmap": { + "groups": [ + { + "title": "Paint properties", + "type": "properties", + "fields": [ + "heatmap-radius", + "heatmap-weight", + "heatmap-intensity", + "heatmap-opacity" + ] + } + ] + }, + "invalid": { + "groups": [] + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/sprites/Sprite.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/sprites/Sprite.kt new file mode 100644 index 00000000..21116964 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/sprites/Sprite.kt @@ -0,0 +1,14 @@ +package ovh.plrapps.mapcompose.vector.spec.sprites + +import kotlinx.serialization.Serializable + +@Serializable +data class Sprite( + val width: Int, + val height: Int, + val x: Int, + val y: Int, + val stretchX: Pair, Pair>? = null, + val stretchY: Pair, Pair>? = null, + val sdf: Boolean = false, +) \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/Layer.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/Layer.kt new file mode 100644 index 00000000..92854f17 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/Layer.kt @@ -0,0 +1,206 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator +import ovh.plrapps.mapcompose.vector.spec.style.serializers.FeatureFilterSerializer +import ovh.plrapps.mapcompose.vector.spec.style.serializers.FilterHolder +import ovh.plrapps.mapcompose.vector.spec.style.background.BackgroundLayout +import ovh.plrapps.mapcompose.vector.spec.style.background.BackgroundPaint +import ovh.plrapps.mapcompose.vector.spec.style.circle.CircleLayout +import ovh.plrapps.mapcompose.vector.spec.style.circle.CirclePaint +import ovh.plrapps.mapcompose.vector.spec.style.fill.FillLayout +import ovh.plrapps.mapcompose.vector.spec.style.fill.FillPaint +import ovh.plrapps.mapcompose.vector.spec.style.fillExtrusion.FillExtrusionLayout +import ovh.plrapps.mapcompose.vector.spec.style.fillExtrusion.FillExtrusionPaint +import ovh.plrapps.mapcompose.vector.spec.style.heatmap.HeatmapLayout +import ovh.plrapps.mapcompose.vector.spec.style.heatmap.HeatmapPaint +import ovh.plrapps.mapcompose.vector.spec.style.hillshade.HillshadeLayout +import ovh.plrapps.mapcompose.vector.spec.style.hillshade.HillshadePaint +import ovh.plrapps.mapcompose.vector.spec.style.line.LineLayout +import ovh.plrapps.mapcompose.vector.spec.style.line.LinePaint +import ovh.plrapps.mapcompose.vector.spec.style.raster.RasterLayout +import ovh.plrapps.mapcompose.vector.spec.style.raster.RasterPaint +import ovh.plrapps.mapcompose.vector.spec.style.sky.SkyLayout +import ovh.plrapps.mapcompose.vector.spec.style.sky.SkyPaint +import ovh.plrapps.mapcompose.vector.spec.style.symbol.SymbolLayout +import ovh.plrapps.mapcompose.vector.spec.style.symbol.SymbolPaint + +@OptIn(ExperimentalSerializationApi::class) +@JsonClassDiscriminator("type") +@Serializable +sealed class Layer { + abstract val id: String + abstract val type: String + abstract val source: String? + @SerialName("source-layer") + abstract val sourceLayer: String? + @Serializable(with = FeatureFilterSerializer::class) + abstract val filter: FilterHolder? + abstract val minzoom: Double? + abstract val maxzoom: Double? + abstract val layout: LayoutInterface? + abstract val paint: PaintInterface? +} + +@Serializable +@SerialName("line") +data class LineLayer( + override val id: String, + override val type: String = "line", + override val source: String? = null, + @SerialName("source-layer") + override val sourceLayer: String? = null, + @Serializable(with = FeatureFilterSerializer::class) + override val filter: FilterHolder? = null, + override val minzoom: Double? = null, + override val maxzoom: Double? = null, + override val layout: LineLayout? = null, + override val paint: LinePaint? = null, +) : Layer() + +@Serializable +@SerialName("fill") +data class FillLayer( + override val id: String, + override val type: String = "fill", + override val source: String? = null, + @SerialName("source-layer") + override val sourceLayer: String? = null, + @Serializable(with = FeatureFilterSerializer::class) + override val filter: FilterHolder? = null, + override val minzoom: Double? = null, + override val maxzoom: Double? = null, + override val layout: FillLayout? = null, + override val paint: FillPaint? = null, +) : Layer() + +@Serializable +@SerialName("symbol") +data class SymbolLayer( + override val id: String, + override val type: String = "symbol", + override val source: String? = null, + @SerialName("source-layer") + override val sourceLayer: String? = null, + @Serializable(with = FeatureFilterSerializer::class) + override val filter: FilterHolder? = null, + override val minzoom: Double? = null, + override val maxzoom: Double? = null, + override val layout: SymbolLayout? = null, + override val paint: SymbolPaint? = null, +) : Layer() + +@Serializable +@SerialName("circle") +data class CircleLayer( + override val id: String, + override val type: String = "circle", + override val source: String? = null, + @SerialName("source-layer") + override val sourceLayer: String? = null, + @Serializable(with = FeatureFilterSerializer::class) + override val filter: FilterHolder? = null, + override val minzoom: Double? = null, + override val maxzoom: Double? = null, + override val layout: CircleLayout? = null, + override val paint: CirclePaint? = null, +) : Layer() + +@Serializable +@SerialName("background") +data class BackgroundLayer( + override val id: String, + override val type: String = "background", + override val source: String? = null, + @SerialName("source-layer") + override val sourceLayer: String? = null, + @Serializable(with = FeatureFilterSerializer::class) + override val filter: FilterHolder? = null, + override val minzoom: Double? = null, + override val maxzoom: Double? = null, + override val layout: BackgroundLayout? = null, + override val paint: BackgroundPaint? = null, +) : Layer() + +@Serializable +@SerialName("raster") +data class RasterLayer( + override val id: String, + override val type: String = "raster", + override val source: String? = null, + @SerialName("source-layer") + override val sourceLayer: String? = null, + @Serializable(with = FeatureFilterSerializer::class) + override val filter: FilterHolder? = null, + override val minzoom: Double? = null, + override val maxzoom: Double? = null, + override val layout: RasterLayout? = null, + override val paint: RasterPaint? = null, +) : Layer() + +@Serializable +@SerialName("hillshade") +data class HillshadeLayer( + override val id: String, + override val type: String = "hillshade", + override val source: String? = null, + @SerialName("source-layer") + override val sourceLayer: String? = null, + @Serializable(with = FeatureFilterSerializer::class) + override val filter: FilterHolder? = null, + override val minzoom: Double? = null, + override val maxzoom: Double? = null, + override val layout: HillshadeLayout? = null, + override val paint: HillshadePaint? = null, +) : Layer() + +@Serializable +@SerialName("heatmap") +data class HeatmapLayer( + override val id: String, + override val type: String = "heatmap", + override val source: String? = null, + @SerialName("source-layer") + override val sourceLayer: String? = null, + @Serializable(with = FeatureFilterSerializer::class) + override val filter: FilterHolder? = null, + override val minzoom: Double? = null, + override val maxzoom: Double? = null, + override val layout: HeatmapLayout? = null, + override val paint: HeatmapPaint? = null, +) : Layer() + +@Serializable +@SerialName("fill-extrusion") +data class FillExtrusionLayer( + override val id: String, + override val type: String = "fill-extrusion", + override val source: String? = null, + @SerialName("source-layer") + override val sourceLayer: String? = null, + @Serializable(with = FeatureFilterSerializer::class) + override val filter: FilterHolder? = null, + override val layout: FillExtrusionLayout? = null, + override val paint: FillExtrusionPaint? = null, + override val minzoom: Double? = null, + override val maxzoom: Double? = null +) : Layer() + +@Serializable +@SerialName("sky") +data class SkyLayer( + override val id: String, + override val type: String = "sky", + override val source: String? = null, + @SerialName("source-layer") + override val sourceLayer: String? = null, + @Serializable(with = FeatureFilterSerializer::class) + override val filter: FilterHolder? = null, + override val minzoom: Double? = null, + override val maxzoom: Double? = null, + override val layout: SkyLayout? = null, + override val paint: SkyPaint? = null, +) : Layer() + diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/LayoutInterface.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/LayoutInterface.kt new file mode 100644 index 00000000..7dae1dc6 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/LayoutInterface.kt @@ -0,0 +1,5 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +interface LayoutInterface { + val visibility: String? +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/LayoutType.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/LayoutType.kt new file mode 100644 index 00000000..00237a42 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/LayoutType.kt @@ -0,0 +1,21 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +enum class LayoutType { + FILL, + LINE, + SYMBOL, + CIRCLE, + HEATMAP, + RASTER, + HILLSHADE, + FILL_EXTRUSION, + UNDEFINED, + BACKGROUND; + + companion object { + fun parse(type: String): LayoutType { + val normalized = type.uppercase().replace('-', '_').trim() + return LayoutType.entries.find { it.name == normalized } ?: UNDEFINED + } + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/MapLibreStyle.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/MapLibreStyle.kt new file mode 100644 index 00000000..f42b15e2 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/MapLibreStyle.kt @@ -0,0 +1,56 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.* +import ovh.plrapps.mapcompose.vector.data.json + +/** + * https://maplibre.org/maplibre-style-spec/ + */ +@Serializable +data class MapLibreStyle( + @SerialName("version") var version: Int? = null, + @SerialName("name") var name: String? = null, + @SerialName("center") var center: List = emptyList(), + @SerialName("zoom") var zoom: Float? = null, + @SerialName("bearing") var bearing: Double? = null, + @SerialName("pitch") var pitch: Double? = null, + @SerialName("sources") var sources: Map? = emptyMap(), + @SerialName("sprite") var sprite: JsonElement? = null, + @SerialName("glyphs") var glyphs: String? = null, + @SerialName("layers") var layers: List = emptyList(), + @SerialName("id") var id: String? = null +) + +@Serializable +data class Source( + val type: String? = null, + + // For TileJSON reference + val url: String? = null, + + // For inline tile source definition + val tiles: List? = null, + val minzoom: Int? = null, + val maxzoom: Int? = null, + val attribution: String? = null +) + +@Serializable +data class SpriteSource(val id: String?, val url: String?) + +val MapLibreStyle.sprites: List + get() { + val element = this.sprite + return when(element) { + is JsonPrimitive -> listOf(SpriteSource(id = "", element.contentOrNull)) + is JsonArray -> { + json.decodeFromJsonElement(ListSerializer(SpriteSource.serializer()), element) + } + is JsonObject, + JsonNull, + null -> emptyList() + } + } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/PaintInterface.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/PaintInterface.kt new file mode 100644 index 00000000..49010a35 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/PaintInterface.kt @@ -0,0 +1,3 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +interface PaintInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/background/BackgroundLayout.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/background/BackgroundLayout.kt new file mode 100644 index 00000000..cbfb6319 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/background/BackgroundLayout.kt @@ -0,0 +1,9 @@ +package ovh.plrapps.mapcompose.vector.spec.style.background + +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.LayoutInterface + +@Serializable +data class BackgroundLayout( + override val visibility: String? = "visible" +) : LayoutInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/background/BackgroundPaint.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/background/BackgroundPaint.kt new file mode 100644 index 00000000..456bd0bb --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/background/BackgroundPaint.kt @@ -0,0 +1,21 @@ +package ovh.plrapps.mapcompose.vector.spec.style.background + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.PaintInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValueColorSerializer + +@Serializable +data class BackgroundPaint( + @SerialName("background-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val backgroundColor: ExpressionOrValue? = null, + + @SerialName("background-pattern") + val backgroundPattern: ExpressionOrValue? = null, + + @SerialName("background-opacity") + val backgroundOpacity: ExpressionOrValue? = null +) : PaintInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/circle/CircleLayout.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/circle/CircleLayout.kt new file mode 100644 index 00000000..4ec55a5b --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/circle/CircleLayout.kt @@ -0,0 +1,13 @@ +package ovh.plrapps.mapcompose.vector.spec.style.circle + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.LayoutInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue + +@Serializable +data class CircleLayout( + @SerialName("circle-sort-key") + val circleSortKey: ExpressionOrValue? = null, + override val visibility: String? = "visible" +) : LayoutInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/circle/CirclePaint.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/circle/CirclePaint.kt new file mode 100644 index 00000000..f97f54dc --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/circle/CirclePaint.kt @@ -0,0 +1,50 @@ +package ovh.plrapps.mapcompose.vector.spec.style.circle + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.PaintInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValueColorSerializer + +@Serializable +data class CirclePaint( + @SerialName("circle-radius") + val circleRadius: ExpressionOrValue? = null, + + @SerialName("circle-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val circleColor: ExpressionOrValue? = null, + + @SerialName("circle-blur") + val circleBlur: ExpressionOrValue? = null, + + @SerialName("circle-opacity") + val circleOpacity: ExpressionOrValue? = null, + + @SerialName("circle-translate") + val circleTranslate: ExpressionOrValue>? = null, + + @SerialName("circle-translate-anchor") + val circleTranslateAnchor: ExpressionOrValue? = null, + + @SerialName("circle-pitch-scale") + val circlePitchScale: ExpressionOrValue? = null, + + @SerialName("circle-pitch-alignment") + val circlePitchAlignment: ExpressionOrValue? = null, + + @SerialName("circle-stroke-width") + val circleStrokeWidth: ExpressionOrValue? = null, + + @SerialName("circle-stroke-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val circleStrokeColor: ExpressionOrValue? = null, + + @SerialName("circle-stroke-opacity") + val circleStrokeOpacity: ExpressionOrValue? = null, + + @SerialName("circle-sort-key") + val circleSortKey: ExpressionOrValue? = null + +) : PaintInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/CompoundExpression.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/CompoundExpression.kt new file mode 100644 index 00000000..02fce830 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/CompoundExpression.kt @@ -0,0 +1,506 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +import androidx.compose.ui.graphics.Color +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Coercion +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Assertion +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Collator as CollatorExpression +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Distance +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.GlobalState +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Literal +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Var +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Within +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.Collator +import kotlin.math.E +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.acos +import kotlin.math.asin +import kotlin.math.atan +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.ln +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan + +/** A fixed-arity or variadic parameter list. */ +sealed class Signature { + data class Fixed(val params: List) : Signature() + data class Varargs(val type: ExprType) : Signature() +} + +typealias Evaluate = (ctx: EvaluationContext, args: List, key: String) -> Any? + +/** One operator's type, signature(s) and evaluation function. */ +sealed class Definition { + abstract val type: ExprType + abstract val overloads: List> + + data class Simple( + override val type: ExprType, + val signature: Signature, + val evaluate: Evaluate, + ) : Definition() { + override val overloads: List> get() = listOf(signature to evaluate) + } + + data class Overloaded( + override val type: ExprType, + override val overloads: List>, + ) : Definition() +} + +/** + * The table-driven operators. + * + * Ported from `maplibre-style-spec/src/expression/compound_expression.ts`. Operators whose parsing + * is uniform (fixed or variadic argument list, all arguments parsed with a known expected type) + * live in [definitions]; anything needing custom parse logic gets its own class under + * `definitions/`. + */ +class CompoundExpression( + val name: String, + override val type: ExprType, + private val evaluateFn: Evaluate, + val args: List, + val key: String, +) : Expression { + + override fun evaluate(ctx: EvaluationContext): Any? = evaluateFn(ctx, args, key) + + override fun eachChild(fn: (Expression) -> Unit) = args.forEach(fn) + + override fun outputDefined(): Boolean = false + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + val op = args[0] as String + val definition = definitions[op] + ?: return context.error( + "Unknown expression \"$op\". If you wanted a literal array, use [\"literal\", [...]].", + 0, + ) + + val type = definition.type + val availableOverloads = definition.overloads + + // Only overloads whose parameter count matches are candidates. + val overloads = availableOverloads.filter { (signature, _) -> + signature !is Signature.Fixed || signature.params.size == args.size - 1 + } + + var signatureContext: ParsingContext? = null + + for ((params, evaluate) in overloads) { + // Use a fresh context for each attempted signature so that, if we eventually + // succeed, we haven't polluted context.errors. + val sc = ParsingContext( + registry = context.registry, + isConstantFunc = ::isExpressionConstant, + path = context.path, + expectedType = null, + scope = context.scope, + ) + signatureContext = sc + + val parsedArgs = mutableListOf() + var argParseFailed = false + for (i in 1 until args.size) { + val arg = args[i] + val expectedType = when (params) { + is Signature.Fixed -> params.params.getOrNull(i - 1) + is Signature.Varargs -> params.type + } + val parsed = sc.parse(arg, 1 + parsedArgs.size, expectedType) + if (parsed == null) { + argParseFailed = true + break + } + parsedArgs.add(parsed) + } + if (argParseFailed) continue + + if (params is Signature.Fixed && params.params.size != parsedArgs.size) { + sc.error("Expected ${params.params.size} arguments, but found ${parsedArgs.size} instead.") + continue + } + + for (i in parsedArgs.indices) { + val expected = when (params) { + is Signature.Fixed -> params.params[i] + is Signature.Varargs -> params.type + } + sc.concat(i + 1).checkSubtype(expected, parsedArgs[i].type) + } + + if (sc.errors.isEmpty()) { + return CompoundExpression(op, type, evaluate, parsedArgs, context.key) + } + } + + if (overloads.size == 1) { + context.errors.addAll(signatureContext?.errors.orEmpty()) + } else { + val expected = overloads.ifEmpty { availableOverloads } + val signatures = expected.joinToString(" | ") { (params, _) -> stringifySignature(params) } + + val actualTypes = mutableListOf() + // For the error message, re-parse arguments without applying any coercions. + for (i in 1 until args.size) { + val parsed = context.parse(args[i], 1 + actualTypes.size) ?: return null + actualTypes.add(typeToString(parsed.type)) + } + context.error("Expected arguments of type $signatures, but found (${actualTypes.joinToString(", ")}) instead.") + } + + return null + } + + private fun stringifySignature(signature: Signature): String = when (signature) { + is Signature.Fixed -> "(${signature.params.joinToString(", ") { typeToString(it) }})" + is Signature.Varargs -> "(${typeToString(signature.type)}...)" + } + + private fun fixed(vararg params: ExprType): Signature = Signature.Fixed(params.toList()) + private fun varargs(type: ExprType): Signature = Signature.Varargs(type) + + private fun simple(type: ExprType, signature: Signature, evaluate: Evaluate): Definition = + Definition.Simple(type, signature, evaluate) + + private fun num(e: Expression, ctx: EvaluationContext): Double = (e.evaluate(ctx) as Number).toDouble() + private fun str(e: Expression, ctx: EvaluationContext): String = e.evaluate(ctx) as String + + private fun rgba(ctx: EvaluationContext, args: List, key: String): Any { + val r = args[0].evaluate(ctx) + val g = args[1].evaluate(ctx) + val b = args[2].evaluate(ctx) + val alpha = if (args.size > 3) args[3].evaluate(ctx) else 1.0 + val error = validateRGBA(r, g, b, alpha) + if (error != null) throw RuntimeError(error, key) + return Color( + red = ((r as Number).toDouble() / 255.0).toFloat(), + green = ((g as Number).toDouble() / 255.0).toFloat(), + blue = ((b as Number).toDouble() / 255.0).toFloat(), + alpha = (alpha as Number).toDouble().toFloat(), + ) + } + + private fun hasKey(key: Any?, obj: Map<*, *>): Boolean = obj.containsKey(key) + + private fun getKey(key: Any?, obj: Map<*, *>): Any? = obj[key] + + private fun binarySearch(v: Any?, a: List, from: Int, to: Int): Boolean { + var i = from + var j = to + while (i <= j) { + val m = (i + j) / 2 + val cmp = compareJsValues(a[m], v) ?: return false + if (cmp == 0) return true + if (cmp > 0) j = m - 1 else i = m + 1 + } + return false + } + + /** `Math.round` semantics: ties toward +Infinity, then upstream's away-from-zero fixup. */ + private fun jsRound(v: Double): Double = floor(v + 0.5) + + private fun roundAwayFromZero(v: Double): Double = if (v < 0) -jsRound(-v) else jsRound(v) + + /** + * Comparison used by the legacy `filter-*` operators and by [binarySearch]: JavaScript's + * relational operators only produce a meaningful result when both operands have the same + * `typeof`, which upstream checks explicitly. Returns `null` for mismatched types. + */ + private fun compareJsValues(a: Any?, b: Any?): Int? = when { + a is Number && b is Number -> a.toDouble().compareTo(b.toDouble()) + a is String && b is String -> a.compareTo(b) + a is Boolean && b is Boolean -> a.compareTo(b) + else -> null + } + + private val RELATIONAL_TESTS: List Boolean>> = listOf( + "<" to { c: Int -> c < 0 }, + ">" to { c: Int -> c > 0 }, + "<=" to { c: Int -> c <= 0 }, + ">=" to { c: Int -> c >= 0 }, + ) + + private fun literalValue(e: Expression): Any? = (e as? Literal)?.value + + @Suppress("UNCHECKED_CAST") + val definitions: Map = buildMap { + put("error", simple(ErrorType, fixed(StringType)) { ctx, args, key -> + throw RuntimeError(args[0].evaluate(ctx) as String, key) + }) + put("typeof", simple(StringType, fixed(ValueType)) { ctx, args, _ -> + typeToString(typeOf(args[0].evaluate(ctx))) + }) + put("to-rgba", simple(array(NumberType, 4), fixed(ColorType)) { ctx, args, _ -> + val c = args[0].evaluate(ctx) as Color + listOf(c.red * 255.0, c.green * 255.0, c.blue * 255.0, c.alpha.toDouble()) + }) + put("rgb", simple(ColorType, fixed(NumberType, NumberType, NumberType), ::rgba)) + put("rgba", simple(ColorType, fixed(NumberType, NumberType, NumberType, NumberType), ::rgba)) + put( + "has", Definition.Overloaded( + BooleanType, listOf( + fixed(StringType) to { ctx: EvaluationContext, args: List, _: String -> + hasKey(args[0].evaluate(ctx), ctx.properties()) + }, + fixed(StringType, ObjectType) to { ctx: EvaluationContext, args: List, _: String -> + hasKey(args[0].evaluate(ctx), args[1].evaluate(ctx) as Map<*, *>) + }, + ) + ) + ) + put( + "get", Definition.Overloaded( + ValueType, listOf( + fixed(StringType) to { ctx: EvaluationContext, args: List, _: String -> + getKey(args[0].evaluate(ctx), ctx.properties()) + }, + fixed(StringType, ObjectType) to { ctx: EvaluationContext, args: List, _: String -> + getKey(args[0].evaluate(ctx), args[1].evaluate(ctx) as Map<*, *>) + }, + ) + ) + ) + put("feature-state", simple(ValueType, fixed(StringType)) { ctx, args, _ -> + getKey(args[0].evaluate(ctx), ctx.featureState ?: emptyMap()) + }) + put("properties", simple(ObjectType, fixed()) { ctx, _, _ -> ctx.properties() }) + put("geometry-type", simple(StringType, fixed()) { ctx, _, _ -> ctx.geometryType() }) + put("id", simple(ValueType, fixed()) { ctx, _, _ -> ctx.id() }) + put("zoom", simple(NumberType, fixed()) { ctx, _, _ -> ctx.globals?.zoom ?: 0.0 }) + put("heatmap-density", simple(NumberType, fixed()) { ctx, _, _ -> ctx.globals?.heatmapDensity ?: 0.0 }) + put("elevation", simple(NumberType, fixed()) { ctx, _, _ -> ctx.globals?.elevation ?: 0.0 }) + put("line-progress", simple(NumberType, fixed()) { ctx, _, _ -> ctx.globals?.lineProgress ?: 0.0 }) + put("accumulated", simple(ValueType, fixed()) { ctx, _, _ -> ctx.globals?.accumulated }) + + put("+", simple(NumberType, varargs(NumberType)) { ctx, args, _ -> + var result = 0.0 + for (arg in args) result += num(arg, ctx) + result + }) + put("*", simple(NumberType, varargs(NumberType)) { ctx, args, _ -> + var result = 1.0 + for (arg in args) result *= num(arg, ctx) + result + }) + put( + "-", Definition.Overloaded( + NumberType, listOf( + fixed(NumberType, NumberType) to { ctx: EvaluationContext, args: List, _: String -> + num(args[0], ctx) - num(args[1], ctx) + }, + fixed(NumberType) to { ctx: EvaluationContext, args: List, _: String -> + -num(args[0], ctx) + }, + ) + ) + ) + put("/", simple(NumberType, fixed(NumberType, NumberType)) { ctx, args, _ -> + num(args[0], ctx) / num(args[1], ctx) + }) + put("%", simple(NumberType, fixed(NumberType, NumberType)) { ctx, args, _ -> + num(args[0], ctx) % num(args[1], ctx) + }) + put("ln2", simple(NumberType, fixed()) { _, _, _ -> ln(2.0) }) + put("pi", simple(NumberType, fixed()) { _, _, _ -> PI }) + put("e", simple(NumberType, fixed()) { _, _, _ -> E }) + put("^", simple(NumberType, fixed(NumberType, NumberType)) { ctx, args, _ -> + num(args[0], ctx).pow(num(args[1], ctx)) + }) + put("sqrt", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> sqrt(num(args[0], ctx)) }) + put("log10", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> ln(num(args[0], ctx)) / ln(10.0) }) + put("ln", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> ln(num(args[0], ctx)) }) + put("log2", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> ln(num(args[0], ctx)) / ln(2.0) }) + put("sin", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> sin(num(args[0], ctx)) }) + put("cos", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> cos(num(args[0], ctx)) }) + put("tan", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> tan(num(args[0], ctx)) }) + put("asin", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> asin(num(args[0], ctx)) }) + put("acos", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> acos(num(args[0], ctx)) }) + put("atan", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> atan(num(args[0], ctx)) }) + put("min", simple(NumberType, varargs(NumberType)) { ctx, args, _ -> + // Math.min() with no arguments is +Infinity in JavaScript. + args.fold(Double.POSITIVE_INFINITY) { acc, arg -> minOf(acc, num(arg, ctx)) } + }) + put("max", simple(NumberType, varargs(NumberType)) { ctx, args, _ -> + args.fold(Double.NEGATIVE_INFINITY) { acc, arg -> maxOf(acc, num(arg, ctx)) } + }) + put("abs", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> abs(num(args[0], ctx)) }) + put("round", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> + roundAwayFromZero(num(args[0], ctx)) + }) + put("floor", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> floor(num(args[0], ctx)) }) + put("ceil", simple(NumberType, fixed(NumberType)) { ctx, args, _ -> ceil(num(args[0], ctx)) }) + + // Internal operators produced by the legacy-filter converter. They are never written + // by style authors; see spec/style/filter/FeatureFilter.kt. + put("filter-==", simple(BooleanType, fixed(StringType, ValueType)) { ctx, args, _ -> + jsStrictEqual(ctx.properties()[literalValue(args[0])], literalValue(args[1])) + }) + put("filter-id-==", simple(BooleanType, fixed(ValueType)) { ctx, args, _ -> + jsStrictEqual(ctx.id(), literalValue(args[0])) + }) + put("filter-type-==", simple(BooleanType, fixed(StringType)) { ctx, args, _ -> + ctx.geometryType() == literalValue(args[0]) + }) + for ((op, test) in RELATIONAL_TESTS) { + put("filter-$op", simple(BooleanType, fixed(StringType, ValueType)) { ctx, args, _ -> + val cmp = compareJsValues(ctx.properties()[literalValue(args[0])], literalValue(args[1])) + cmp != null && test(cmp) + }) + put("filter-id-$op", simple(BooleanType, fixed(ValueType)) { ctx, args, _ -> + val cmp = compareJsValues(ctx.id(), literalValue(args[0])) + cmp != null && test(cmp) + }) + } + put("filter-has", simple(BooleanType, fixed(ValueType)) { ctx, args, _ -> + hasKey(literalValue(args[0]), ctx.properties()) + }) + put("filter-has-id", simple(BooleanType, fixed()) { ctx, _, _ -> ctx.id() != null }) + put("filter-type-in", simple(BooleanType, fixed(array(StringType))) { ctx, args, _ -> + (literalValue(args[0]) as List).contains(ctx.geometryType()) + }) + put("filter-id-in", simple(BooleanType, fixed(array(ValueType))) { ctx, args, _ -> + (literalValue(args[0]) as List).any { jsStrictEqual(it, ctx.id()) } + }) + put("filter-in-small", simple(BooleanType, fixed(StringType, array(ValueType))) { ctx, args, _ -> + val v = literalValue(args[1]) as List + val target = ctx.properties()[literalValue(args[0])] + v.any { jsStrictEqual(it, target) } + }) + put("filter-in-large", simple(BooleanType, fixed(StringType, array(ValueType))) { ctx, args, _ -> + val v = literalValue(args[1]) as List + binarySearch(ctx.properties()[literalValue(args[0])], v, 0, v.size - 1) + }) + + put( + "all", Definition.Overloaded( + BooleanType, listOf( + fixed(BooleanType, BooleanType) to { ctx: EvaluationContext, args: List, _: String -> + (args[0].evaluate(ctx) as Boolean) && (args[1].evaluate(ctx) as Boolean) + }, + varargs(BooleanType) to { ctx: EvaluationContext, args: List, _: String -> + args.all { it.evaluate(ctx) as Boolean } + }, + ) + ) + ) + put( + "any", Definition.Overloaded( + BooleanType, listOf( + fixed(BooleanType, BooleanType) to { ctx: EvaluationContext, args: List, _: String -> + (args[0].evaluate(ctx) as Boolean) || (args[1].evaluate(ctx) as Boolean) + }, + varargs(BooleanType) to { ctx: EvaluationContext, args: List, _: String -> + args.any { it.evaluate(ctx) as Boolean } + }, + ) + ) + ) + put("!", simple(BooleanType, fixed(BooleanType)) { ctx, args, _ -> + !(args[0].evaluate(ctx) as Boolean) + }) + put("is-supported-script", simple(BooleanType, fixed(StringType)) { ctx, args, _ -> + ctx.globals?.isSupportedScript?.invoke(str(args[0], ctx)) ?: true + }) + put("upcase", simple(StringType, fixed(StringType)) { ctx, args, _ -> str(args[0], ctx).uppercase() }) + put("downcase", simple(StringType, fixed(StringType)) { ctx, args, _ -> str(args[0], ctx).lowercase() }) + put("concat", simple(StringType, varargs(ValueType)) { ctx, args, _ -> + args.joinToString(separator = "") { valueToString(it.evaluate(ctx)) } + }) + put("split", simple(array(StringType), fixed(StringType, StringType)) { ctx, args, _ -> + val s = str(args[0], ctx) + val delim = str(args[1], ctx) + // String.prototype.split('') splits into characters. + if (delim.isEmpty()) s.map { it.toString() } else s.split(delim) + }) + put("join", simple(StringType, fixed(array(StringType), StringType)) { ctx, args, _ -> + (args[0].evaluate(ctx) as List).joinToString(str(args[1], ctx)) { valueToString(it) } + }) + put("resolved-locale", simple(StringType, fixed(CollatorType)) { ctx, args, _ -> + (args[0].evaluate(ctx) as Collator).resolvedLocale() + }) + } + } +} + +/** + * Whether an expression's value is independent of the feature being rendered. + * + * Ported from `isFeatureConstant` in `compound_expression.ts`. + */ +fun isFeatureConstant(e: Expression): Boolean { + if (e is CompoundExpression) { + if (e.name == "get" && e.args.size == 1) return false + if (e.name == "feature-state") return false + if (e.name == "has" && e.args.size == 1) return false + if (e.name == "properties" || e.name == "geometry-type" || e.name == "id") return false + if (e.name.startsWith("filter-")) return false + } + if (e is Within || e is Distance) return false + + var result = true + e.eachChild { arg -> if (result && !isFeatureConstant(arg)) result = false } + return result +} + +/** Ported from `isStateConstant`. */ +fun isStateConstant(e: Expression): Boolean { + if (e is CompoundExpression && e.name == "feature-state") return false + var result = true + e.eachChild { arg -> if (result && !isStateConstant(arg)) result = false } + return result +} + +/** Ported from `isGlobalPropertyConstant`. */ +fun isGlobalPropertyConstant(e: Expression, properties: List): Boolean { + if (e is CompoundExpression && e.name in properties) return false + var result = true + e.eachChild { arg -> if (result && !isGlobalPropertyConstant(arg, properties)) result = false } + return result +} + +private val GLOBAL_PROPERTY_NAMES = listOf( + "zoom", "heatmap-density", "elevation", "line-progress", "accumulated", "is-supported-script", +) + +/** + * Whether an expression can be evaluated once at parse time and replaced with its value. + * + * Ported from `isExpressionConstant`. + */ +fun isExpressionConstant(expression: Expression): Boolean { + when (expression) { + is Var -> return isExpressionConstant(expression.boundExpression) + is CompoundExpression -> if (expression.name == "error") return false + // Although a Collator with fixed arguments generally shouldn't change between executions, + // we can't fold it because the result depends on the environment. + is CollatorExpression -> return false + is Within -> return false + is Distance -> return false + is GlobalState -> return false + else -> Unit + } + + val isTypeAnnotation = expression is Coercion || expression is Assertion + + var childrenConstant = true + expression.eachChild { child -> + // We can almost assume that if an expression's children are constant they would already + // have been folded into Literals when parsed. Type annotations are the exception, because + // they might have been inferred and added after a child was parsed. + childrenConstant = if (isTypeAnnotation) { + childrenConstant && isExpressionConstant(child) + } else { + childrenConstant && child is Literal + } + } + if (!childrenConstant) return false + + return isFeatureConstant(expression) && isGlobalPropertyConstant(expression, GLOBAL_PROPERTY_NAMES) +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/CreateExpression.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/CreateExpression.kt new file mode 100644 index 00000000..17121779 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/CreateExpression.kt @@ -0,0 +1,238 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Coalesce +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.GlobalState +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Interpolate +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.InterpolationType +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Let +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Step +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.expressions + +/** + * The outcome of compiling an expression: either a usable expression or the list of parse errors. + * + * Ported from `Result` in `maplibre-style-spec/src/util/result.ts`. Parsing never throws — callers + * decide whether to surface the errors or fall back. + */ +sealed class ExpressionResult { + data class Success(val value: T) : ExpressionResult() + data class Error(val errors: List) : ExpressionResult() + + val valueOrNull: T? get() = (this as? Success)?.value +} + +/** Whether a JSON value looks like an expression, i.e. an array headed by a known operator name. */ +fun isExpression(expression: Any?): Boolean = + expression is List<*> && expression.isNotEmpty() && + expression[0] is String && expression[0] in expressions + +/** + * Parses and type-checks a style-spec JSON expression. + * + * Ported from `createExpression`. [rootKey] identifies the expression's location in the style JSON + * (e.g. `layers[3].paint.line-width`) and is used to prefix runtime warnings. + */ +fun createExpression( + expression: Any?, + rootKey: String, + propertySpec: StylePropertySpec? = null, + globalState: Map? = null, +): ExpressionResult { + require(rootKey.isNotEmpty()) { + "rootKey must identify the location of the expression in the style JSON, " + + "e.g. \"layers[3].paint.line-width\"." + } + + val parser = ParsingContext( + registry = expressions, + isConstantFunc = ::isExpressionConstant, + expectedType = propertySpec?.expectedType, + ) + + // For string-valued properties, coerce to string at the top level rather than asserting. + val parsed = parser.parse( + expression, + typeAnnotation = if (propertySpec?.isStringProperty == true) { + ParsingContext.TypeAnnotation.COERCE + } else { + null + }, + ) ?: return ExpressionResult.Error(parser.errors.toList()) + + return ExpressionResult.Success(StyleExpression(parsed, rootKey, propertySpec, globalState)) +} + +/** How a compiled property expression varies. Ported from `EvaluationKind`. */ +enum class EvaluationKind { CONSTANT, SOURCE, CAMERA, COMPOSITE } + +/** + * A compiled style property expression, with the static analysis that tells the renderer how often + * it needs re-evaluating. + * + * Ported from `ZoomConstantExpression` / `ZoomDependentExpression`. The two upstream classes are + * merged here because their evaluate methods are identical; [zoomStops] and [interpolationType] are + * simply null for the zoom-constant kinds. + */ +class StylePropertyExpression( + val kind: EvaluationKind, + val styleExpression: StyleExpression, + val zoomStops: List? = null, + val interpolationType: InterpolationType? = null, +) { + val isStateDependent: Boolean = when (kind) { + EvaluationKind.CONSTANT, EvaluationKind.CAMERA -> false + else -> !isStateConstant(styleExpression.expression) + } + + val globalStateRefs: Set = findGlobalStateRefs(styleExpression.expression) + + fun interpolationFactor(input: Double, lower: Double, upper: Double): Double = + interpolationType?.let { Interpolate.interpolationFactor(it, input, lower, upper) } ?: 0.0 +} + +/** + * Compiles an expression for a style property, running the zoom/feature static analysis. + * + * Ported from `createPropertyExpression`. + */ +fun createPropertyExpression( + expressionInput: Any?, + rootKey: String, + propertySpec: StylePropertySpec, + globalState: Map? = null, +): ExpressionResult { + val expression = when (val r = createExpression(expressionInput, rootKey, propertySpec, globalState)) { + is ExpressionResult.Error -> return r + is ExpressionResult.Success -> r.value + } + + val parsed = expression.expression + + val isFeatureConstantResult = isFeatureConstant(parsed) + if (!isFeatureConstantResult && !propertySpec.supportsPropertyExpression) { + return ExpressionResult.Error(listOf(ExpressionParsingError("", "data expressions not supported"))) + } + + val zoomConstant = isGlobalPropertyConstant(parsed, listOf("zoom")) + if (!zoomConstant && !propertySpec.supportsZoomExpression) { + return ExpressionResult.Error(listOf(ExpressionParsingError("", "zoom expressions not supported"))) + } + + val zoomCurve = findZoomCurve(parsed) + if (zoomCurve is ZoomCurve.Invalid) { + return ExpressionResult.Error(listOf(zoomCurve.error)) + } + val curve = (zoomCurve as? ZoomCurve.Found)?.expression + if (curve == null && !zoomConstant) { + return ExpressionResult.Error( + listOf( + ExpressionParsingError( + "", + "\"zoom\" expression may only be used as input to a top-level \"step\" or " + + "\"interpolate\" expression.", + ) + ) + ) + } + if (curve is Interpolate && !propertySpec.supportsInterpolation) { + return ExpressionResult.Error( + listOf(ExpressionParsingError("", "\"interpolate\" expressions cannot be used with this property")) + ) + } + + if (curve == null) { + return ExpressionResult.Success( + StylePropertyExpression( + kind = if (isFeatureConstantResult) EvaluationKind.CONSTANT else EvaluationKind.SOURCE, + styleExpression = expression, + ) + ) + } + + val interpolationType = (curve as? Interpolate)?.interpolation + val labels = when (curve) { + is Interpolate -> curve.labels + is Step -> curve.labels + else -> emptyList() + } + + return ExpressionResult.Success( + StylePropertyExpression( + kind = if (isFeatureConstantResult) EvaluationKind.CAMERA else EvaluationKind.COMPOSITE, + styleExpression = expression, + zoomStops = labels, + interpolationType = interpolationType, + ) + ) +} + +/** The result of scanning for the single permitted top-level zoom curve. */ +sealed class ZoomCurve { + data class Found(val expression: Expression) : ZoomCurve() + data class Invalid(val error: ExpressionParsingError) : ZoomCurve() + data object None : ZoomCurve() +} + +/** + * Finds the `step`/`interpolate` expression that takes `["zoom"]` as its input. + * + * Ported from `findZoomCurve`. `zoom` is only allowed at the top level of one such curve, so this + * also reports the two ways that rule can be broken. + */ +fun findZoomCurve(expression: Expression): ZoomCurve { + var result: ZoomCurve = ZoomCurve.None + + when { + expression is Let -> result = findZoomCurve(expression.result) + + expression is Coalesce -> { + for (arg in expression.args) { + result = findZoomCurve(arg) + if (result != ZoomCurve.None) break + } + } + + expression is Step && expression.input.isZoom() -> result = ZoomCurve.Found(expression) + expression is Interpolate && expression.input.isZoom() -> result = ZoomCurve.Found(expression) + } + + if (result is ZoomCurve.Invalid) return result + + expression.eachChild { child -> + val childResult = findZoomCurve(child) + if (childResult is ZoomCurve.Invalid) { + result = childResult + } else if (result == ZoomCurve.None && childResult is ZoomCurve.Found) { + result = ZoomCurve.Invalid( + ExpressionParsingError( + "", + "\"zoom\" expression may only be used as input to a top-level \"step\" or " + + "\"interpolate\" expression.", + ) + ) + } else if (result is ZoomCurve.Found && childResult is ZoomCurve.Found && + (result as ZoomCurve.Found).expression !== childResult.expression + ) { + result = ZoomCurve.Invalid( + ExpressionParsingError( + "", + "Only one zoom-based \"step\" or \"interpolate\" subexpression may be used in " + + "an expression.", + ) + ) + } + } + + return result +} + +private fun Expression.isZoom(): Boolean = this is CompoundExpression && name == "zoom" + +/** Collects the keys referenced by `global-state` expressions. Ported from `findGlobalStateRefs`. */ +fun findGlobalStateRefs(expression: Expression, results: MutableSet = mutableSetOf()): Set { + if (expression is GlobalState) { + results.add(expression.stateKey) + } + expression.eachChild { child -> findGlobalStateRefs(child, results) } + return results +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Errors.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Errors.kt new file mode 100644 index 00000000..8a855f97 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Errors.kt @@ -0,0 +1,17 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +/** + * A parse-time error. Ported from `maplibre-style-spec/src/expression/parsing_error.ts`. + * + * These are *collected* into [ParsingContext.errors] rather than thrown, so one bad expression + * never aborts parsing of the surrounding style. + */ +data class ExpressionParsingError(val key: String, override val message: String) : Exception(message) + +/** + * An evaluation-time error. Ported from `maplibre-style-spec/src/expression/runtime_error.ts`. + * + * [path] is the index path of the throwing sub-expression (e.g. `[3][0]`); the empty string means + * the throw is at the root of the expression. + */ +class RuntimeError(override val message: String, val path: String = "") : Exception(message) diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/EvaluationContext.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/EvaluationContext.kt new file mode 100644 index 00000000..9a297f39 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/EvaluationContext.kt @@ -0,0 +1,82 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +import androidx.compose.ui.graphics.Color +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.FormattedSection +import ovh.plrapps.mapcompose.vector.spec.style.utils.ColorParser + +/** A point in MVT tile-local coordinates (0..extent). */ +data class Point2D(val x: Double, val y: Double) + +/** The `(z, x, y)` address of the tile a feature came from. Needed by `within` and `distance`. */ +data class CanonicalTileId(val z: Int, val x: Int, val y: Int) + +/** + * The feature an expression is evaluated against. + * + * Ported from the `Feature` type in `maplibre-style-spec/src/expression/index.ts`. + * + * [properties] values must already be normalized to engine values — see [normalizeNumbers]. + * [geometry] is in MVT tile-local coordinates and is a lambda so that decoding is skipped entirely + * unless an expression actually asks for it (see `geometryNeeded` in the filter engine). + */ +class EvalFeature( + val type: String, + val id: Any? = null, + val properties: Map = emptyMap(), + private val geometryProvider: (() -> List>)? = null, +) { + val geometry: List> by lazy { geometryProvider?.invoke() ?: emptyList() } + + companion object { + val GEOMETRY_TYPES = listOf("Unknown", "Point", "LineString", "Polygon") + } +} + +typealias FeatureState = Map + +/** + * Camera / render-pass state. Ported from `GlobalProperties`. + * + * [isSupportedScript] is a hook so the renderer can answer for the fonts it actually has; the + * default accepts everything, matching MapLibre's behaviour when no font stack is configured. + */ +data class GlobalProperties( + val zoom: Double, + val heatmapDensity: Double? = null, + val elevation: Double? = null, + val lineProgress: Double? = null, + val accumulated: Any? = null, + val globalState: Map? = null, + val isSupportedScript: ((String) -> Boolean)? = null, +) + +/** + * Mutable evaluation state, reused across evaluations to avoid per-feature allocation — exactly as + * upstream does (`StyleExpression._evaluator`). + * + * Ported from `maplibre-style-spec/src/expression/evaluation_context.ts`. + */ +class EvaluationContext { + var globals: GlobalProperties? = null + var feature: EvalFeature? = null + var featureState: FeatureState? = null + var formattedSection: FormattedSection? = null + var availableImages: List? = null + var canonical: CanonicalTileId? = null + + private val parseColorCache = mutableMapOf() + + fun id(): Any? = feature?.id + + fun geometryType(): String? = feature?.type + + fun geometry(): List> = feature?.geometry ?: emptyList() + + fun canonicalID(): CanonicalTileId? = canonical + + fun properties(): Map = feature?.properties ?: emptyMap() + + fun parseColor(input: String): Color? = parseColorCache.getOrPut(input) { + ColorParser.parseColorStringOrNull(input) + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Expression.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Expression.kt new file mode 100644 index 00000000..76ff772c --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Expression.kt @@ -0,0 +1,31 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +/** + * A parsed, type-checked expression node. + * + * Ported from `maplibre-style-spec/src/expression/expression.ts`. + */ +interface Expression { + val type: ExprType + + fun evaluate(ctx: EvaluationContext): Any? + + fun eachChild(fn: (Expression) -> Unit) + + /** + * Statically analyze the expression, attempting to enumerate possible outputs. Returns false if + * the complete set of outputs is statically undecidable, otherwise true. + */ + fun outputDefined(): Boolean +} + +/** + * A parser for one operator. Upstream models this as a static `parse` on the expression class; + * Kotlin uses a function value held in the registry. + * + * Returning `null` means "a parsing error has already been pushed into the context". + */ +typealias ExpressionParser = (args: List, context: ParsingContext) -> Expression? + +/** Operator name to parser. Ported from `ExpressionRegistry`. */ +typealias ExpressionRegistry = Map diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/JsSemantics.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/JsSemantics.kt new file mode 100644 index 00000000..33b6a01f --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/JsSemantics.kt @@ -0,0 +1,112 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +/** + * The handful of JavaScript coercion rules the expression spec is defined in terms of. + * + * MapLibre's reference implementation is JavaScript, and several operators are specified as + * `Boolean(x)` or `Number(x)`. Reproducing those exactly is what makes the upstream conformance + * fixtures pass, so they are isolated here rather than scattered through the definitions. + */ + +/** `Boolean(x)` — JavaScript truthiness. */ +fun jsTruthy(value: Any?): Boolean = when (value) { + null -> false + is Boolean -> value + is Number -> { + val d = value.toDouble() + d != 0.0 && !d.isNaN() + } + + is String -> value.isNotEmpty() + else -> true +} + +/** `Number(x)` — returns [Double.NaN] where JavaScript would. */ +fun jsToNumber(value: Any?): Double = when (value) { + null -> 0.0 + is Boolean -> if (value) 1.0 else 0.0 + is Number -> value.toDouble() + is String -> { + val trimmed = value.trim() + when { + trimmed.isEmpty() -> 0.0 + trimmed.startsWith("0x") || trimmed.startsWith("0X") -> + trimmed.substring(2).toLongOrNull(16)?.toDouble() ?: Double.NaN + + trimmed == "Infinity" || trimmed == "+Infinity" -> Double.POSITIVE_INFINITY + trimmed == "-Infinity" -> Double.NEGATIVE_INFINITY + else -> trimmed.toDoubleOrNull() ?: Double.NaN + } + } + + is List<*> -> when { + value.isEmpty() -> 0.0 + value.size == 1 -> jsToNumber(value[0]) + else -> Double.NaN + } + + else -> Double.NaN +} + +/** + * `a === b` — strict equality. Arrays and objects compare by identity in JavaScript, so this is + * deliberately *not* [deepEqual]; the legacy `filter-*` operators are specified in terms of `===`. + */ +fun jsStrictEqual(a: Any?, b: Any?): Boolean = when { + a is Number && b is Number -> a.toDouble() == b.toDouble() + a is List<*> || b is List<*> || a is Map<*, *> || b is Map<*, *> -> a === b + else -> a == b +} + +/** `Array.prototype.indexOf` — strict equality, optional start index, `-1` when absent. */ +fun jsIndexOf(list: List, needle: Any?, fromIndex: Int = 0): Int { + var start = fromIndex + if (start < 0) start += list.size + if (start < 0) start = 0 + for (i in start until list.size) { + if (jsStrictEqual(list[i], needle)) return i + } + return -1 +} + +/** Normalizes JavaScript's relative slice bounds (negative counts from the end, clamped). */ +fun jsSliceRange(size: Int, beginIn: Int, endIn: Int?): IntRange { + var begin = if (beginIn < 0) size + beginIn else beginIn + begin = begin.coerceIn(0, size) + var end = if (endIn == null) size else if (endIn < 0) size + endIn else endIn + end = end.coerceIn(0, size) + return if (begin >= end) IntRange.EMPTY else begin until end +} + +/** Splits a string into Unicode code points, matching JavaScript's `[...str]`. */ +fun String.toCodePointStrings(): List { + val out = ArrayList(length) + var i = 0 + while (i < length) { + val c = this[i] + if (c.isHighSurrogate() && i + 1 < length && this[i + 1].isLowSurrogate()) { + out.add(substring(i, i + 2)) + i += 2 + } else { + out.add(c.toString()) + i++ + } + } + return out +} + +/** + * `String(x)` — JavaScript's string conversion. + * + * Distinct from `valueToString`, which is MapLibre's own value formatter and renders `null` as the + * empty string. `String.prototype.indexOf` and friends coerce their argument with `String()`, so + * `"abc".indexOf(null)` searches for the literal text `"null"`. + */ +fun jsStringify(value: Any?): String = when (value) { + null -> "null" + is Boolean -> value.toString() + is Number -> formatNumber(value.toDouble()) + is String -> value + is List<*> -> value.joinToString(",") { if (it == null) "" else jsStringify(it) } + else -> value.toString() +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/JsonValue.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/JsonValue.kt new file mode 100644 index 00000000..dc287848 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/JsonValue.kt @@ -0,0 +1,40 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.doubleOrNull + +/** + * Bridges kotlinx-serialization's [JsonElement] and the engine's plain-Kotlin value model. + * + * The expression parser works on `Any?` because MapLibre's parser works on plain JavaScript values. + * Numbers become [Double] on the way in, so the engine sees JavaScript-like numbers throughout — + * see the note on [normalizeNumbers]. + */ +fun jsonToValue(element: JsonElement): Any? = when (element) { + is JsonNull -> null + is JsonPrimitive -> when { + element.isString -> element.content + else -> element.booleanOrNull ?: element.doubleOrNull ?: element.content + } + + is JsonArray -> element.map { jsonToValue(it) } + is JsonObject -> element.entries.associate { (k, v) -> k to jsonToValue(v) } +} + +/** The inverse of [jsonToValue], used when re-serializing an expression from its parsed form. */ +fun valueToJson(value: Any?): JsonElement = when (value) { + null -> JsonNull + is Boolean -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is String -> JsonPrimitive(value) + is List<*> -> buildJsonArray { value.forEach { add(valueToJson(it)) } } + is Map<*, *> -> buildJsonObject { value.forEach { (k, v) -> put(k.toString(), valueToJson(v)) } } + else -> JsonPrimitive(value.toString()) +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/LegacyFunction.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/LegacyFunction.kt new file mode 100644 index 00000000..13ae6a1c --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/LegacyFunction.kt @@ -0,0 +1,303 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +/** + * Converts a legacy (v7) *function object* — `{stops}`, `{type}`, `{property}`, `{base}` — into an + * equivalent v8 expression. + * + * Ported from `maplibre-style-spec/src/function/convert.ts`. Values here are already plain Kotlin + * (see [jsonToValue]), and the output is plain Kotlin too, ready for [createExpression]. + * + * Token strings (`"{name}"`) are only converted when [StylePropertySpec.tokens] is set, which + * MapCompose leaves off: `SymbolLayerPainter` performs token substitution itself, so converting + * here as well would double-apply it. + */ +fun convertLegacyFunction(parameters: Map, propertySpec: StylePropertySpec): Any? { + val rawStops = parameters["stops"] as? List<*> + ?: return convertIdentityFunction(parameters, propertySpec) + + val zoomAndFeatureDependent = (rawStops.firstOrNull() as? List<*>)?.firstOrNull() is Map<*, *> + val featureDependent = zoomAndFeatureDependent || parameters.containsKey("property") + val zoomDependent = zoomAndFeatureDependent || !featureDependent + + val stops = rawStops.mapNotNull { stop -> + val pair = stop as? List<*> ?: return@mapNotNull null + if (pair.size != 2) return@mapNotNull null + val output = pair[1] + if (!featureDependent && propertySpec.tokens && output is String) { + pair[0] to convertTokenString(output) + } else { + pair[0] to convertLiteral(output) + } + } + + return when { + zoomAndFeatureDependent -> convertZoomAndPropertyFunction(parameters, propertySpec, stops) + zoomDependent -> convertZoomFunction(parameters, propertySpec, stops) + else -> convertPropertyFunction(parameters, propertySpec, stops) + } +} + +private fun convertLiteral(value: Any?): Any? = + if (value is Map<*, *> || value is List<*>) listOf("literal", value) else value + +private fun convertIdentityFunction(parameters: Map, propertySpec: StylePropertySpec): Any? { + val property = parameters["property"] ?: return null + val get = listOf("get", property) + + if (!parameters.containsKey("default")) { + // Expressions for string-valued properties get coerced by default; to preserve legacy + // function semantics, insert an explicit assertion instead. + return if (propertySpec.expectedType == StringType) listOf("string", get) else get + } + + val default = parameters["default"] + propertySpec.enumValues?.let { values -> + return listOf("match", get, values.toList(), get, default) + } + + val assertion = when (val t = propertySpec.expectedType) { + ColorType -> "to-color" + StringType -> "string" + NumberType -> "number" + BooleanType -> "boolean" + is ArrayType -> "array" + else -> return listOf("coalesce", get, convertLiteral(default)) + } + + return if (propertySpec.expectedType is ArrayType) { + val itemType = (propertySpec.expectedType as ArrayType).itemType + listOf(assertion, itemType.kind, (propertySpec.expectedType as ArrayType).n, get, convertLiteral(default)) + } else { + listOf(assertion, get, convertLiteral(default)) + } +} + +private fun interpolateOperator(parameters: Map): String = when (parameters["colorSpace"]) { + "hcl" -> "interpolate-hcl" + "lab" -> "interpolate-lab" + else -> "interpolate" +} + +private fun functionType(parameters: Map, propertySpec: StylePropertySpec): String = + parameters["type"] as? String ?: if (propertySpec.supportsInterpolation) "exponential" else "interval" + +/** + * The value a `categorical` / `case` conversion falls back to when the feature matches no stop. + * + * Upstream uses `parameters.default ?: propertySpec.default`, and every real MapLibre property has + * a default in the generated style-spec metadata. MapCompose models properties as Kotlin data + * classes and has no such table, so when neither is available a type-appropriate neutral literal is + * substituted: without one the converted `match` would carry a `null` output and fail to type-check + * against the property's type, taking the whole property down with it. + */ +private fun fallback(parameters: Map, propertySpec: StylePropertySpec): Any? { + if (parameters.containsKey("default")) return convertLiteral(parameters["default"]) + if (propertySpec.defaultValue != null) return convertLiteral(propertySpec.defaultValue) + return neutralDefaultFor(propertySpec.expectedType) +} + +private fun neutralDefaultFor(type: ExprType?): Any? = when (type) { + ColorType -> "rgba(0,0,0,0)" + NumberType -> 0.0 + StringType -> "" + BooleanType -> false + is ArrayType -> listOf("literal", emptyList()) + else -> null +} + +private fun convertPropertyFunction( + parameters: Map, + propertySpec: StylePropertySpec, + stops: List>, +): Any? { + val type = functionType(parameters, propertySpec) + val get = listOf("get", parameters["property"]) + + return when { + type == "categorical" && stops.firstOrNull()?.first is Boolean -> { + val expression = mutableListOf("case") + for ((input, output) in stops) { + expression.add(listOf("==", get, input)) + expression.add(output) + } + expression.add(fallback(parameters, propertySpec)) + expression + } + + type == "categorical" -> { + val expression = mutableListOf("match", get) + for ((input, output) in stops) appendStopPair(expression, input, output, isStep = false) + expression.add(fallback(parameters, propertySpec)) + expression + } + + type == "interval" -> { + val expression = mutableListOf("step", listOf("number", get)) + for ((input, output) in stops) appendStopPair(expression, input, output, isStep = true) + fixupDegenerateStepCurve(expression) + if (!parameters.containsKey("default")) { + expression + } else { + listOf( + "case", + listOf("==", listOf("typeof", get), "number"), + expression, + convertLiteral(parameters["default"]), + ) + } + } + + type == "exponential" -> { + val base = (parameters["base"] as? Number)?.toDouble() ?: 1.0 + val expression = mutableListOf( + interpolateOperator(parameters), + if (base == 1.0) listOf("linear") else listOf("exponential", base), + listOf("number", get), + ) + for ((input, output) in stops) appendStopPair(expression, input, output, isStep = false) + if (!parameters.containsKey("default")) { + expression + } else { + listOf( + "case", + listOf("==", listOf("typeof", get), "number"), + expression, + convertLiteral(parameters["default"]), + ) + } + } + + else -> null + } +} + +private fun convertZoomFunction( + parameters: Map, + propertySpec: StylePropertySpec, + stops: List>, + input: Any? = listOf("zoom"), +): Any? { + val type = functionType(parameters, propertySpec) + val expression: MutableList + val isStep: Boolean + + when (type) { + "interval" -> { + expression = mutableListOf("step", input) + isStep = true + } + + "exponential" -> { + val base = (parameters["base"] as? Number)?.toDouble() ?: 1.0 + expression = mutableListOf( + interpolateOperator(parameters), + if (base == 1.0) listOf("linear") else listOf("exponential", base), + input, + ) + isStep = false + } + + else -> return null + } + + for ((stopInput, output) in stops) appendStopPair(expression, stopInput, output, isStep) + fixupDegenerateStepCurve(expression) + return expression +} + +private fun convertZoomAndPropertyFunction( + parameters: Map, + propertySpec: StylePropertySpec, + stops: List>, +): Any? { + val featureFunctionParameters = linkedMapOf>() + val featureFunctionStops = linkedMapOf>>() + val zoomStops = mutableListOf() + + for ((rawInput, output) in stops) { + val stopObject = rawInput as? Map<*, *> ?: continue + val zoom = (stopObject["zoom"] as? Number)?.toDouble() ?: continue + if (zoom !in featureFunctionParameters) { + featureFunctionParameters[zoom] = buildMap { + put("zoom", zoom) + if (parameters.containsKey("type")) put("type", parameters["type"]) + if (parameters.containsKey("property")) put("property", parameters["property"]) + if (parameters.containsKey("default")) put("default", parameters["default"]) + } + featureFunctionStops[zoom] = mutableListOf() + zoomStops.add(zoom) + } + featureFunctionStops.getValue(zoom).add(stopObject["value"] to output) + } + + // The interpolation type for the zoom dimension of a zoom-and-property function comes from the + // property spec: linear for interpolatable properties, step otherwise. + return if (propertySpec.supportsInterpolation) { + val expression = mutableListOf(interpolateOperator(parameters), listOf("linear"), listOf("zoom")) + for (z in zoomStops) { + val output = convertPropertyFunction( + featureFunctionParameters.getValue(z), propertySpec, featureFunctionStops.getValue(z), + ) + appendStopPair(expression, z, output, isStep = false) + } + expression + } else { + val expression = mutableListOf("step", listOf("zoom")) + for (z in zoomStops) { + val output = convertPropertyFunction( + featureFunctionParameters.getValue(z), propertySpec, featureFunctionStops.getValue(z), + ) + appendStopPair(expression, z, output, isStep = true) + } + fixupDegenerateStepCurve(expression) + expression + } +} + +/** + * A degenerate step curve (a constant function) needs a no-op stop to be a valid expression. + * + * **Deliberate divergence.** Upstream reads `expression[3]` *after* pushing `0`, so it appends the + * `0` it just pushed and turns every constant legacy function into `0` above zoom 0. This repeats + * the intended output instead, keeping the function constant. + */ +private fun fixupDegenerateStepCurve(expression: MutableList) { + if (expression.firstOrNull() == "step" && expression.size == 3) { + expression.add(0.0) + expression.add(expression[2]) + } +} + +private fun appendStopPair(curve: MutableList, input: Any?, output: Any?, isStep: Boolean) { + // Skip duplicate stop values: functions never validated them, but expressions do. + if (curve.size > 3 && input == curve[curve.size - 2]) return + // Step curves omit the first input value, which is redundant. + if (!(isStep && curve.size == 2)) curve.add(input) + curve.add(output) +} + +/** + * `"String with {name} token"` becomes `["concat", "String with ", ["get", "name"], " token"]`. + * + * Ported from `convertTokenString` in `maplibre-style-spec/src/function/convert.ts`. + */ +fun convertTokenString(s: String): Any? { + val result = mutableListOf("concat") + val regex = Regex("\\{([^{}]+)\\}") + var pos = 0 + for (match in regex.findAll(s)) { + val literal = s.substring(pos, match.range.first) + pos = match.range.last + 1 + if (literal.isNotEmpty()) result.add(literal) + result.add(listOf("get", match.groupValues[1])) + } + + if (result.size == 1) return s + if (pos < s.length) { + result.add(s.substring(pos)) + } else if (result.size == 2) { + return listOf("to-string", result[1]) + } + + return result +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/ParsingContext.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/ParsingContext.kt new file mode 100644 index 00000000..5aab4086 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/ParsingContext.kt @@ -0,0 +1,175 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Assertion +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Coercion +import ovh.plrapps.mapcompose.vector.spec.style.expression.definitions.Literal + +/** + * State associated with parsing at a given point in an expression tree. + * + * Ported from `maplibre-style-spec/src/expression/parsing_context.ts`. + * + * Two behaviours here carry most of the engine's weight: + * + * 1. **Automatic type annotation.** When the expected type is a concrete one and the parsed + * sub-expression is of the open `value` type, the result is wrapped in an [Assertion] (or a + * [Coercion] for color/formatted/image). This is what makes `["interpolate", …, ["get","x"], …]` + * work: `get` returns `value`, and the assertion narrows it to `number` at evaluation time. + * 2. **Constant folding.** A node whose arguments are all literals is evaluated immediately and + * replaced with a [Literal]. + * + * Errors are pushed into [errors] rather than thrown, so a single bad expression never aborts + * parsing of the surrounding style. + */ +class ParsingContext( + val registry: ExpressionRegistry, + private val isConstantFunc: (Expression) -> Boolean, + val path: List = emptyList(), + /** + * The expected type of this expression. Provided only to allow expression implementations to + * infer argument types: a parser need not check that its output type matches [expectedType]. + */ + val expectedType: ExprType? = null, + val scope: Scope = Scope(), + val errors: MutableList = mutableListOf(), +) { + val key: String = path.joinToString(separator = "") { "[$it]" } + + enum class TypeAnnotation { ASSERT, COERCE, OMIT } + + fun parse( + expr: Any?, + index: Int? = null, + expectedType: ExprType? = null, + bindings: List>? = null, + typeAnnotation: TypeAnnotation? = null, + ): Expression? { + return if (index != null && index != 0) { + concat(index, expectedType, bindings).parseInternal(expr, typeAnnotation) + } else { + parseInternal(expr, typeAnnotation) + } + } + + private fun parseInternal(exprIn: Any?, typeAnnotation: TypeAnnotation?): Expression? { + var expr = exprIn + if (expr == null || expr is String || expr is Boolean || expr is Number) { + expr = listOf("literal", expr) + } + + if (expr is List<*>) { + if (expr.isEmpty()) { + return error( + "Expected an array with at least one element. If you wanted a literal array, " + + "use [\"literal\", []]." + ) + } + + val op = expr[0] + if (op !is String) { + error( + "Expression name must be a string, but found ${jsTypeName(op)} instead. " + + "If you wanted a literal array, use [\"literal\", [...]].", + 0, + ) + return null + } + + val parser = registry[op] + ?: return error( + "Unknown expression \"$op\". If you wanted a literal array, use [\"literal\", [...]].", + 0, + ) + + var parsed = parser(expr, this) ?: return null + + val expected = expectedType + if (expected != null) { + val actual = parsed.type + // When we expect a number, string, boolean, object or array but have a value, wrap + // it in an assertion. When we expect a color, formatted string or image but have a + // string or value, wrap it in a coercion. Otherwise, static type checking. + if ((expected == StringType || expected == NumberType || expected == BooleanType || + expected == ObjectType || expected is ArrayType) && actual == ValueType + ) { + parsed = annotate(parsed, expected, typeAnnotation ?: TypeAnnotation.ASSERT) + } else if ((expected == ColorType || expected == FormattedType || expected == ResolvedImageType) && + (actual == ValueType || actual == StringType) + ) { + parsed = annotate(parsed, expected, typeAnnotation ?: TypeAnnotation.COERCE) + } else if (checkSubtype(expected, actual) != null) { + return null + } + } + + // If an expression's arguments are all literals, we can evaluate it immediately and + // replace it with a literal value in the parsed result. Expressions that expect an + // image should not be resolved here so we can later get the available images. + if (parsed !is Literal && parsed.type != ResolvedImageType && isConstantFunc(parsed)) { + val ec = EvaluationContext() + try { + parsed = Literal(parsed.type, parsed.evaluate(ec)) + } catch (e: Exception) { + error(e.message ?: "Evaluation error") + return null + } + } + + return parsed + } + + if (expr is Map<*, *>) { + return error("Bare objects invalid. Use [\"literal\", {...}] instead.") + } + + return error("Expected an array, but found ${jsTypeName(expr)} instead.") + } + + private fun annotate(parsed: Expression, type: ExprType, typeAnnotation: TypeAnnotation): Expression = + when (typeAnnotation) { + TypeAnnotation.ASSERT -> Assertion(type, listOf(parsed), key) + TypeAnnotation.COERCE -> Coercion(type, listOf(parsed), key) + TypeAnnotation.OMIT -> parsed + } + + /** + * Returns a copy of this context suitable for parsing the sub-expression at [index], optionally + * appending to the `let` binding map. + * + * Note that [errors] is shared by reference rather than cloned, exactly as upstream. + */ + fun concat( + index: Int?, + expectedType: ExprType? = null, + bindings: List>? = null, + ): ParsingContext { + val newPath = if (index != null) path + index else path + val newScope = if (bindings != null) scope.concat(bindings) else scope + return ParsingContext(registry, isConstantFunc, newPath, expectedType, newScope, errors) + } + + /** Pushes a parsing (or type-checking) error and returns `null` for convenient early return. */ + fun error(message: String, vararg keys: Int): Expression? { + val k = "$key${keys.joinToString(separator = "") { "[$it]" }}" + errors.add(ExpressionParsingError(k, message)) + return null + } + + /** Returns `null` if [t] is a subtype of [expected]; otherwise records and returns the message. */ + fun checkSubtype(expected: ExprType, t: ExprType): String? { + val err = ovh.plrapps.mapcompose.vector.spec.style.expression.checkSubtype(expected, t) + if (err != null) error(err) + return err + } + + private companion object { + fun jsTypeName(v: Any?): String = when (v) { + null -> "object" + is String -> "string" + is Boolean -> "boolean" + is Number -> "number" + is List<*> -> "object" + else -> "object" + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Scope.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Scope.kt new file mode 100644 index 00000000..5a90efda --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Scope.kt @@ -0,0 +1,22 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +/** + * Tracks `let` bindings during expression parsing. + * + * Ported from `maplibre-style-spec/src/expression/scope.ts`. Note that `var` resolution happens at + * *parse* time: [Var] looks the name up here and holds a direct reference to the bound expression, + * so there is no binding map at evaluation time. + */ +class Scope( + private val parent: Scope? = null, + bindings: List> = emptyList(), +) { + private val bindings: Map = bindings.toMap() + + fun concat(bindings: List>): Scope = Scope(this, bindings) + + fun get(name: String): Expression = + bindings[name] ?: parent?.get(name) ?: error("$name not found in scope.") + + fun has(name: String): Boolean = bindings.containsKey(name) || (parent?.has(name) ?: false) +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Stops.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Stops.kt new file mode 100644 index 00000000..513cb79f --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Stops.kt @@ -0,0 +1,36 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +import kotlin.math.floor + +typealias Stops = List> + +/** + * Returns the index of the last stop <= input, or 0 if it doesn't exist. + * + * Ported from `maplibre-style-spec/src/expression/stops.ts`. + */ +fun findStopLessThanOrEqualTo(stops: List, input: Double, key: String): Int { + val lastIndex = stops.size - 1 + var lowerIndex = 0 + var upperIndex = lastIndex + var currentIndex: Int + + while (lowerIndex <= upperIndex) { + currentIndex = floor((lowerIndex + upperIndex) / 2.0).toInt() + val currentValue = stops[currentIndex] + val nextValue = stops.getOrNull(currentIndex + 1) + + if (currentValue <= input) { + if (currentIndex == lastIndex || (nextValue != null && input < nextValue)) { + return currentIndex + } + lowerIndex = currentIndex + 1 + } else if (currentValue > input) { + upperIndex = currentIndex - 1 + } else { + throw RuntimeError("Input is not a number.", key) + } + } + + return 0 +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/StyleExpression.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/StyleExpression.kt new file mode 100644 index 00000000..34d5147d --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/StyleExpression.kt @@ -0,0 +1,107 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.FormattedSection + +/** + * Where the engine reports runtime problems. + * + * Upstream calls `console.warn`; Kotlin Multiplatform has no shared logger, so this is a settable + * hook. It is only ever called once per distinct `path|message` per expression. + */ +object ExpressionLogger { + var warn: (String) -> Unit = { message -> println("[maplibre-expression] $message") } +} + +/** + * A parsed expression together with its error-handling behaviour. + * + * Ported from `StyleExpression` in `maplibre-style-spec/src/expression/index.ts`. + * + * [evaluate] never throws: on a runtime error it warns once per distinct message and returns the + * property's default value. [evaluateWithoutErrorHandling] is the raw path, used by tests and by + * callers that want to observe failures. + */ +class StyleExpression( + val expression: Expression, + private val rootKey: String, + private val propertySpec: StylePropertySpec? = null, + private val globalState: Map? = null, +) { + private val evaluator = EvaluationContext() + private val defaultValue: Any? = propertySpec?.defaultValue + private val enumValues: Set? = propertySpec?.enumValues + private val warningHistory = mutableSetOf() + + private fun prepare( + globals: GlobalProperties, + feature: EvalFeature?, + featureState: FeatureState?, + canonical: CanonicalTileId?, + availableImages: List?, + formattedSection: FormattedSection?, + ) { + evaluator.globals = if (globalState != null) globals.copy(globalState = globalState) else globals + evaluator.feature = feature + evaluator.featureState = featureState + evaluator.canonical = canonical + evaluator.availableImages = availableImages + evaluator.formattedSection = formattedSection + } + + fun evaluateWithoutErrorHandling( + globals: GlobalProperties, + feature: EvalFeature? = null, + featureState: FeatureState? = null, + canonical: CanonicalTileId? = null, + availableImages: List? = null, + formattedSection: FormattedSection? = null, + ): Any? { + prepare(globals, feature, featureState, canonical, availableImages, formattedSection) + return expression.evaluate(evaluator) + } + + fun evaluate( + globals: GlobalProperties, + feature: EvalFeature? = null, + featureState: FeatureState? = null, + canonical: CanonicalTileId? = null, + availableImages: List? = null, + formattedSection: FormattedSection? = null, + ): Any? { + prepare(globals, feature, featureState, canonical, availableImages, formattedSection) + + return try { + val value = expression.evaluate(evaluator) + if (value == null || (value is Double && value.isNaN())) { + return defaultValue + } + if (enumValues != null && value !in enumValues) { + throw RuntimeError( + "Expected value to be one of ${enumValues.joinToString(", ") { "\"$it\"" }}, " + + "but found ${valueToJsonString(value)} instead." + ) + } + value + } catch (e: Exception) { + // A non-RuntimeError throw has no location, so treat it as the root (''). + val path = (e as? RuntimeError)?.path ?: "" + val message = e.message ?: e.toString() + val dedupKey = "$path|$message" + if (warningHistory.add(dedupKey)) { + ExpressionLogger.warn(formatRuntimeWarning(rootKey, path, message, defaultValue)) + } + defaultValue + } + } + + private companion object { + /** + * The warning logged when an expression fails at evaluation: a `rootKey + index path` + * location prefix, plus the fallback value being used. + */ + fun formatRuntimeWarning(rootKey: String, path: String, message: String, defaultValue: Any?): String { + val fallback = if (defaultValue == null) "" else " Falling back to ${valueToString(defaultValue)}." + return "$rootKey$path: $message$fallback" + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/StylePropertySpec.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/StylePropertySpec.kt new file mode 100644 index 00000000..9383760a --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/StylePropertySpec.kt @@ -0,0 +1,44 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +/** + * The subset of MapLibre's `StylePropertySpecification` this engine needs. + * + * Upstream reads a generated JSON metadata file describing every style property. MapCompose models + * style properties as Kotlin data classes instead, so the relevant facts are supplied by the + * property serializer at the point of use: + * + * - [expectedType] drives the parser's automatic assert/coerce annotation; + * - [defaultValue] is what [StyleExpression.evaluate] falls back to on a runtime error — the + * painters already apply MapLibre's spec defaults with `?:`, so `null` is the usual choice here; + * - [enumValues] rejects out-of-range results for enum-typed properties; + * - [supportsPropertyExpression] / [supportsZoomExpression] / [supportsInterpolation] gate the + * data-driven, zoom-driven and interpolatable behaviours. + */ +data class StylePropertySpec( + val expectedType: ExprType?, + val defaultValue: Any? = null, + val enumValues: Set? = null, + val supportsPropertyExpression: Boolean = true, + val supportsZoomExpression: Boolean = true, + val supportsInterpolation: Boolean = true, + /** + * Whether `"{token}"` strings in a legacy function are rewritten into `concat`/`get`. + * + * MapLibre sets this for `text-field` and `icon-image`. MapCompose defaults it to **false** + * because `SymbolLayerPainter` performs token substitution itself, and converting here as well + * would double-apply it. + */ + val tokens: Boolean = false, +) { + /** True for `string`-typed properties, which coerce rather than assert at the top level. */ + val isStringProperty: Boolean get() = expectedType == StringType + + companion object { + /** The spec a layer `filter` is compiled against. */ + val FILTER = StylePropertySpec( + expectedType = BooleanType, + defaultValue = false, + supportsInterpolation = false, + ) + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Types.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Types.kt new file mode 100644 index 00000000..35b5b3e5 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Types.kt @@ -0,0 +1,137 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +/** + * The MapLibre expression type lattice. + * + * Ported from `maplibre-style-spec/src/expression/types.ts`. + * + * Deliberately omitted, because MapCompose has no style properties of these kinds: + * `projectionDefinition`, `padding`, `numberArray`, `colorArray` and + * `variableAnchorOffsetCollection`. If such a property is ever added, the type must be added + * here *and* to [valueMemberTypes], otherwise `checkSubtype(ValueType, ...)` will reject it. + */ +sealed class ExprType { + abstract val kind: String +} + +data object NullType : ExprType() { + override val kind = "null" +} + +data object NumberType : ExprType() { + override val kind = "number" +} + +data object StringType : ExprType() { + override val kind = "string" +} + +data object BooleanType : ExprType() { + override val kind = "boolean" +} + +data object ColorType : ExprType() { + override val kind = "color" +} + +data object ObjectType : ExprType() { + override val kind = "object" +} + +data object ValueType : ExprType() { + override val kind = "value" +} + +data object ErrorType : ExprType() { + override val kind = "error" +} + +data object CollatorType : ExprType() { + override val kind = "collator" +} + +data object FormattedType : ExprType() { + override val kind = "formatted" +} + +data object ResolvedImageType : ExprType() { + override val kind = "resolvedImage" +} + +/** `N == null` means "any length". */ +data class ArrayType(val itemType: ExprType, val n: Int? = null) : ExprType() { + override val kind = "array" +} + +fun array(itemType: ExprType, n: Int? = null): ArrayType = ArrayType(itemType, n) + +fun typeToString(type: ExprType): String = when (type) { + is ArrayType -> { + val itemType = typeToString(type.itemType) + when { + type.n != null -> "array<$itemType, ${type.n}>" + type.itemType == ValueType -> "array" + else -> "array<$itemType>" + } + } + + else -> type.kind +} + +private val valueMemberTypes: List = listOf( + NullType, + NumberType, + StringType, + BooleanType, + ColorType, + FormattedType, + ObjectType, + array(ValueType), + ResolvedImageType, +) + +/** + * Returns `null` if [t] is a subtype of [expected]; otherwise an error message. + */ +fun checkSubtype(expected: ExprType, t: ExprType): String? { + if (t == ErrorType) { + // Error is a subtype of every type + return null + } else if (expected is ArrayType) { + if (t is ArrayType && + ((t.n == 0 && t.itemType == ValueType) || checkSubtype(expected.itemType, t.itemType) == null) && + (expected.n == null || expected.n == t.n) + ) { + return null + } + } else if (expected.kind == t.kind) { + return null + } else if (expected == ValueType) { + for (memberType in valueMemberTypes) { + if (checkSubtype(memberType, t) == null) { + return null + } + } + } + + return "Expected ${typeToString(expected)} but found ${typeToString(t)} instead." +} + +fun isValidType(provided: ExprType, allowedTypes: List): Boolean = + allowedTypes.any { it.kind == provided.kind } + +/** The JSON-level native types accepted by the `array` assertion. */ +enum class NativeType(val typeName: String) { + NUMBER("number"), STRING("string"), BOOLEAN("boolean"), NULL("null"), ARRAY("array"), OBJECT("object") +} + +fun isValidNativeType(provided: Any?, allowedTypes: List): Boolean = allowedTypes.any { t -> + when (t) { + NativeType.NULL -> provided == null + NativeType.ARRAY -> provided is List<*> + NativeType.OBJECT -> provided is Map<*, *> + NativeType.NUMBER -> provided is Number + NativeType.STRING -> provided is String + NativeType.BOOLEAN -> provided is Boolean + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/UnitBezier.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/UnitBezier.kt new file mode 100644 index 00000000..a08da391 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/UnitBezier.kt @@ -0,0 +1,58 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +import kotlin.math.abs + +/** + * Cubic bezier easing solver, used by `["interpolate", ["cubic-bezier", x1, y1, x2, y2], …]`. + * + * Ported from `@mapbox/unitbezier`, which MapLibre depends on. The control points are `(0,0)`, + * `(x1,y1)`, `(x2,y2)`, `(1,1)`; [solve] maps an x in 0..1 to the corresponding y. + */ +class UnitBezier(private val p1x: Double, private val p1y: Double, private val p2x: Double, private val p2y: Double) { + + // Pre-calculate the polynomial coefficients. + private val cx = 3.0 * p1x + private val bx = 3.0 * (p2x - p1x) - cx + private val ax = 1.0 - cx - bx + + private val cy = 3.0 * p1y + private val by = 3.0 * (p2y - p1y) - cy + private val ay = 1.0 - cy - by + + fun sampleCurveX(t: Double): Double = ((ax * t + bx) * t + cx) * t + + fun sampleCurveY(t: Double): Double = ((ay * t + by) * t + cy) * t + + fun sampleCurveDerivativeX(t: Double): Double = (3.0 * ax * t + 2.0 * bx) * t + cx + + fun solveCurveX(x: Double, epsilon: Double = 1e-6): Double { + // First try a few iterations of Newton's method -- normally very fast. + var t2 = x + for (i in 0 until 8) { + val x2 = sampleCurveX(t2) - x + if (abs(x2) < epsilon) return t2 + val d2 = sampleCurveDerivativeX(t2) + if (abs(d2) < 1e-6) break + t2 -= x2 / d2 + } + + // Fall back to the bisection method for reliability. + var t0 = 0.0 + var t1 = 1.0 + t2 = x + + if (t2 < t0) return t0 + if (t2 > t1) return t1 + + while (t0 < t1) { + val x2 = sampleCurveX(t2) + if (abs(x2 - x) < epsilon) return t2 + if (x > x2) t0 = t2 else t1 = t2 + t2 = (t1 - t0) * 0.5 + t0 + } + + return t2 + } + + fun solve(x: Double, epsilon: Double = 1e-6): Double = sampleCurveY(solveCurveX(x, epsilon)) +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Values.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Values.kt new file mode 100644 index 00000000..c3146fc8 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/Values.kt @@ -0,0 +1,178 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +import androidx.compose.ui.graphics.Color +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.Collator +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.Formatted +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.ResolvedImage + +/** + * Runtime expression values. + * + * Ported from `maplibre-style-spec/src/expression/values.ts`. Upstream models values with a + * `Value` union type; Kotlin keeps them as `Any?` and relies on [typeOf] for dispatch, exactly as + * the TypeScript relies on `typeof` / `instanceof`. + * + * The inhabited set is: + * `null`, [String], [Double], [Boolean], [Color], [Collator], [Formatted], [ResolvedImage], + * `List`, `Map`. + * + * **Numbers are always [Double].** JavaScript has a single number type, and the whole engine's + * equality and comparison semantics depend on that. MVT properties decode as `Int`/`Float`/`Long`/ + * `UInt`, so they are normalized through [normalizeNumbers] at the feature boundary, and JSON + * literals are normalized when parsed. Anything entering the engine as a non-Double [Number] is a + * bug. + */ + +fun validateRGBA(r: Any?, g: Any?, b: Any?, a: Any? = null): String? { + val rd = r as? Double + val gd = g as? Double + val bd = b as? Double + if (!(rd != null && rd in 0.0..255.0 && gd != null && gd in 0.0..255.0 && bd != null && bd in 0.0..255.0)) { + val value = if (a is Double) listOf(r, g, b, a) else listOf(r, g, b) + return "Invalid rgba value [${value.joinToString(", ") { formatNumberForMessage(it) }}]: " + + "'r', 'g', and 'b' must be between 0 and 255." + } + if (!(a == null || (a is Double && a in 0.0..1.0))) { + return "Invalid rgba value [${ + listOf(r, g, b, a).joinToString(", ") { formatNumberForMessage(it) } + }]: 'a' must be between 0 and 1." + } + return null +} + +fun isValue(mixed: Any?): Boolean = when (mixed) { + null, is String, is Boolean, is Double, is Color, is Collator, is Formatted, is ResolvedImage -> true + is List<*> -> mixed.all { isValue(it) } + is Map<*, *> -> mixed.all { (k, v) -> k is String && isValue(v) } + else -> false +} + +fun typeOf(value: Any?): ExprType = when (value) { + null -> NullType + is String -> StringType + is Boolean -> BooleanType + is Number -> NumberType + is Color -> ColorType + is Collator -> CollatorType + is Formatted -> FormattedType + is ResolvedImage -> ResolvedImageType + is List<*> -> { + var itemType: ExprType? = null + for (item in value) { + val t = typeOf(item) + if (itemType == null) { + itemType = t + } else if (itemType == t) { + continue + } else { + itemType = ValueType + break + } + } + array(itemType ?: ValueType, value.size) + } + + else -> ObjectType +} + +fun valueToString(value: Any?): String = when (value) { + null -> "" + is String -> value + is Boolean -> value.toString() + is Number -> formatNumber(value.toDouble()) + is Color -> colorToRgbaString(value) + is Formatted, is ResolvedImage, is Collator -> value.toString() + else -> valueToJsonString(value) +} + +/** Matches `JSON.stringify` closely enough for the error messages and `to-string` outputs. */ +fun valueToJsonString(value: Any?): String = when (value) { + null -> "null" + is String -> "\"${escapeJson(value)}\"" + is Boolean -> value.toString() + is Number -> formatNumber(value.toDouble()) + is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { valueToJsonString(it) } + is Map<*, *> -> value.entries.joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) -> + "\"${escapeJson(k.toString())}\":${valueToJsonString(v)}" + } + + is Color -> "\"${colorToRgbaString(value)}\"" + else -> "\"${escapeJson(value.toString())}\"" +} + +private fun escapeJson(s: String): String { + val sb = StringBuilder(s.length + 2) + for (c in s) { + when (c) { + '"' -> sb.append("\\\"") + '\\' -> sb.append("\\\\") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> if (c.code < 0x20) sb.append("\\u").append(c.code.toString(16).padStart(4, '0')) else sb.append(c) + } + } + return sb.toString() +} + +/** + * Renders a Double the way JavaScript's `String(number)` does: integral values lose the `.0`. + * The engine's error messages and `to-string` results are compared against upstream fixtures, so + * this matters. + */ +fun formatNumber(d: Double): String = when { + d.isNaN() -> "NaN" + d == Double.POSITIVE_INFINITY -> "Infinity" + d == Double.NEGATIVE_INFINITY -> "-Infinity" + d == 0.0 -> "0" + d % 1.0 == 0.0 && kotlin.math.abs(d) < 1e21 -> d.toLong().toString() + else -> d.toString() +} + +private fun formatNumberForMessage(v: Any?): String = if (v is Number) formatNumber(v.toDouble()) else v.toString() + +fun colorToRgbaString(color: Color): String { + val r = kotlin.math.round(color.red * 255.0).toInt() + val g = kotlin.math.round(color.green * 255.0).toInt() + val b = kotlin.math.round(color.blue * 255.0).toInt() + val a = color.alpha.toDouble() + return "rgba($r,$g,$b,${formatNumber(kotlin.math.round(a * 1000.0) / 1000.0)})" +} + +/** + * Deep structural equality, ported from `maplibre-style-spec/src/util/deep_equal.ts`. + * Used by `==` / `!=` / `match` / `in` / `index-of`. + */ +fun deepEqual(a: Any?, b: Any?): Boolean { + if (a is List<*>) { + if (b !is List<*> || a.size != b.size) return false + for (i in a.indices) { + if (!deepEqual(a[i], b[i])) return false + } + return true + } + if (a is Map<*, *>) { + if (b !is Map<*, *> || a.size != b.size) return false + for ((k, v) in a) { + if (!b.containsKey(k)) return false + if (!deepEqual(v, b[k])) return false + } + return true + } + if (a is Number && b is Number) return a.toDouble() == b.toDouble() + return a == b +} + +/** + * Coerces every [Number] in a value tree to [Double], so the engine sees JavaScript-like numbers. + * Applied to MVT feature properties and to JSON literals. + */ +fun normalizeNumbers(value: Any?): Any? = when (value) { + is Double -> value + is Number -> value.toDouble() + is UInt -> value.toDouble() + is ULong -> value.toDouble() + is List<*> -> value.map { normalizeNumbers(it) } + is Map<*, *> -> value.entries.associate { (k, v) -> k.toString() to normalizeNumbers(v) } + else -> value +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/colorspaces/ColorInterpolation.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/colorspaces/ColorInterpolation.kt new file mode 100644 index 00000000..e248fe9f --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/colorspaces/ColorInterpolation.kt @@ -0,0 +1,66 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.colorspaces + +import androidx.compose.ui.graphics.Color + +/** The color spaces `interpolate`, `interpolate-hcl` and `interpolate-lab` operate in. */ +enum class InterpolationColorSpace { RGB, HCL, LAB } + +fun interpolateNumber(from: Double, to: Double, t: Double): Double = from + t * (to - from) + +fun interpolateArray(from: DoubleArray, to: DoubleArray, t: Double): DoubleArray = + DoubleArray(from.size) { i -> interpolateNumber(from[i], to[i], t) } + +/** + * Ported from `Color.interpolate` in `maplibre-style-spec/src/expression/types/color.ts`. + * + * The HCL branch reproduces chroma.js's hue-shortest-path rule, including its handling of the + * achromatic (NaN hue) cases. + */ +fun interpolateColor(from: Color, to: Color, t: Double, space: InterpolationColorSpace): Color = + when (space) { + InterpolationColorSpace.RGB -> + rgbArrayToColor(interpolateArray(from.toRgbArray(), to.toRgbArray(), t)) + + InterpolationColorSpace.HCL -> { + val f = rgbToHcl(from.toRgbArray()) + val to2 = rgbToHcl(to.toRgbArray()) + val hue0 = f[0]; val chroma0 = f[1]; val light0 = f[2]; val alphaF = f[3] + val hue1 = to2[0]; val chroma1 = to2[1]; val light1 = to2[2]; val alphaT = to2[3] + + var hue: Double + var chroma: Double? = null + + if (!hue0.isNaN() && !hue1.isNaN()) { + var dh = hue1 - hue0 + if (hue1 > hue0 && dh > 180) { + dh -= 360 + } else if (hue1 < hue0 && hue0 - hue1 > 180) { + dh += 360 + } + hue = hue0 + t * dh + } else if (!hue0.isNaN()) { + hue = hue0 + if (light1 == 1.0 || light1 == 0.0) chroma = chroma0 + } else if (!hue1.isNaN()) { + hue = hue1 + if (light0 == 1.0 || light0 == 0.0) chroma = chroma1 + } else { + hue = Double.NaN + } + + rgbArrayToColor( + hclToRgb( + doubleArrayOf( + hue, + chroma ?: interpolateNumber(chroma0, chroma1, t), + interpolateNumber(light0, light1, t), + interpolateNumber(alphaF, alphaT, t), + ) + ) + ) + } + + InterpolationColorSpace.LAB -> rgbArrayToColor( + labToRgb(interpolateArray(rgbToLab(from.toRgbArray()), rgbToLab(to.toRgbArray()), t)) + ) + } diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/colorspaces/ColorSpaces.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/colorspaces/ColorSpaces.kt new file mode 100644 index 00000000..4ef3e7c6 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/colorspaces/ColorSpaces.kt @@ -0,0 +1,118 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.colorspaces + +import androidx.compose.ui.graphics.Color +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.round +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * sRGB <-> CIE LAB and HCL conversions, used by `interpolate-lab` and `interpolate-hcl`. + * + * Ported from `maplibre-style-spec/src/expression/types/color_spaces.ts` + * (see https://observablehq.com/@mbostock/lab-and-rgb). The D50 white point and the exact matrix + * coefficients are kept as-is so interpolated colors match MapLibre's output bit for bit. + */ + +/** `[l, a, b, alpha]` — lightness 0..100, a/b axes -125..125, alpha 0..1. */ +typealias LabColor = DoubleArray + +/** `[h, c, l, alpha]` — hue degrees 0..360 (NaN when achromatic), chroma 0..~230, lightness 0..100. */ +typealias HclColor = DoubleArray + +/** `[r, g, b, alpha]`, all 0..1. */ +typealias RgbColor = DoubleArray + +private const val Xn = 0.96422 +private const val Yn = 1.0 +private const val Zn = 0.82521 +private const val T0 = 4.0 / 29.0 +private const val T1 = 6.0 / 29.0 +private const val T2 = 3.0 * T1 * T1 +private const val T3 = T1 * T1 * T1 +private const val DEG_2_RAD = PI / 180.0 +private const val RAD_2_DEG = 180.0 / PI + +private fun constrainAngle(angleIn: Double): Double { + var angle = angleIn % 360.0 + if (angle < 0) angle += 360.0 + return angle +} + +private fun rgb2xyz(x: Double): Double = if (x <= 0.04045) x / 12.92 else ((x + 0.055) / 1.055).pow(2.4) + +private fun xyz2lab(t: Double): Double = if (t > T3) t.pow(1.0 / 3.0) else t / T2 + T0 + +private fun lab2xyz(t: Double): Double = if (t > T1) t * t * t else T2 * (t - T0) + +private fun xyz2rgb(xIn: Double): Double { + val x = if (xIn <= 0.00304) 12.92 * xIn else 1.055 * xIn.pow(1.0 / 2.4) - 0.055 + return if (x < 0) 0.0 else if (x > 1) 1.0 else x +} + +fun rgbToLab(rgb: RgbColor): LabColor { + val r = rgb2xyz(rgb[0]) + val g = rgb2xyz(rgb[1]) + val b = rgb2xyz(rgb[2]) + val alpha = rgb[3] + + val y = xyz2lab((0.2225045 * r + 0.7168786 * g + 0.0606169 * b) / Yn) + val x: Double + val z: Double + if (r == g && g == b) { + x = y + z = y + } else { + x = xyz2lab((0.4360747 * r + 0.3850649 * g + 0.1430804 * b) / Xn) + z = xyz2lab((0.0139322 * r + 0.0971045 * g + 0.7141733 * b) / Zn) + } + + val l = 116 * y - 16 + return doubleArrayOf(if (l < 0) 0.0 else l, 500 * (x - y), 200 * (y - z), alpha) +} + +fun labToRgb(lab: LabColor): RgbColor { + val l = lab[0] + val a = lab[1] + val b = lab[2] + val alpha = lab[3] + + var y = (l + 16) / 116 + var x = if (a.isNaN()) y else y + a / 500 + var z = if (b.isNaN()) y else y - b / 200 + + y = Yn * lab2xyz(y) + x = Xn * lab2xyz(x) + z = Zn * lab2xyz(z) + + return doubleArrayOf( + xyz2rgb(3.1338561 * x - 1.6168667 * y - 0.4906146 * z), // D50 -> sRGB + xyz2rgb(-0.9787684 * x + 1.9161415 * y + 0.033454 * z), + xyz2rgb(0.0719453 * x - 0.2289914 * y + 1.4052427 * z), + alpha, + ) +} + +fun rgbToHcl(rgb: RgbColor): HclColor { + val lab = rgbToLab(rgb) + val a = lab[1] + val b = lab[2] + val c = sqrt(a * a + b * b) + val h = if (round(c * 10000) != 0.0) constrainAngle(atan2(b, a) * RAD_2_DEG) else Double.NaN + return doubleArrayOf(h, c, lab[0], lab[3]) +} + +fun hclToRgb(hcl: HclColor): RgbColor { + val h = if (hcl[0].isNaN()) 0.0 else hcl[0] * DEG_2_RAD + val c = hcl[1] + return labToRgb(doubleArrayOf(hcl[2], cos(h) * c, sin(h) * c, hcl[3])) +} + +fun Color.toRgbArray(): RgbColor = + doubleArrayOf(red.toDouble(), green.toDouble(), blue.toDouble(), alpha.toDouble()) + +fun rgbArrayToColor(rgb: RgbColor): Color = + Color(rgb[0].toFloat(), rgb[1].toFloat(), rgb[2].toFloat(), rgb[3].toFloat()) diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/colorspaces/CssColorParser.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/colorspaces/CssColorParser.kt new file mode 100644 index 00000000..8ad3483f --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/colorspaces/CssColorParser.kt @@ -0,0 +1,298 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.colorspaces + +import kotlin.math.max +import kotlin.math.min + +/** + * CSS color parser compliant with the CSS Color 4 specification. + * + * Ported from `maplibre-style-spec/src/expression/types/parse_css_color.ts`. Supports named colors, + * the `transparent` keyword, all rgb hex notations, and the `rgb()` / `rgba()` / `hsl()` / `hsla()` + * functions in both the comma and the space-separated grammar. Values are **not** rounded to + * integers. + * + * Caveats carried over from upstream: + * - `` is a number with an optional `deg` suffix; `grad`, `rad` and `turn` are not supported + * - the `none` keyword is not supported + * - comments inside `rgb()` / `hsl()` are not supported + * - `rgba()` and `hsla()` are accepted with grammar and behaviour identical to `rgb()` / `hsl()` + * + * @return `[r, g, b, a]` in sRGB with all channels in 0..1, or `null` if the input is not a color. + */ +object CssColorParser { + + fun parse(inputRaw: String): RgbColor? { + val input = inputRaw.lowercase().trim() + + if (input == "transparent") return doubleArrayOf(0.0, 0.0, 0.0, 0.0) + + namedColors[input]?.let { (r, g, b) -> + return doubleArrayOf(r / 255.0, g / 255.0, b / 255.0, 1.0) + } + + // #f0c, #f0cf, #ff00cc, #ff00ccff + if (input.startsWith("#")) { + if (HEX_REGEX.matches(input)) { + val step = if (input.length < 6) 1 else 2 + var i = 1 + fun next(): String { + val slice = input.substring(i, minOf(i + step, input.length)) + i += step + return slice + } + return doubleArrayOf( + parseHex(next()), parseHex(next()), parseHex(next()), + parseHex(input.substring(minOf(i, input.length), minOf(i + step, input.length)).ifEmpty { "ff" }), + ) + } + } + + // rgb(128 0 0), rgb(50% 0% 0%), rgba(255,0,255,0.6), rgb(255 0 255 / 60%) + if (input.startsWith("rgb")) { + val rgbMatch = RGB_REGEX.matchEntire(input) ?: return null + val g = rgbMatch.groupValues + val (r, rp, f1) = Triple(g[1], g[2], g[3]) + val (gr, gp, f2) = Triple(g[4], g[5], g[6]) + val (b, bp, f3) = Triple(g[7], g[8], g[9]) + val a = g[10] + val ap = g[11] + + val argFormat = (f1.ifEmpty { " " }) + (f2.ifEmpty { " " }) + f3 + if (argFormat !in VALID_ARG_FORMATS) return null + + val valFormat = rp + gp + bp + val maxValue = when (valFormat) { + "%%%" -> 100.0 + "" -> 255.0 + else -> return null // values must be all numbers or all percentages + } + + val rgba = doubleArrayOf( + clamp(jsNumber(r) / maxValue, 0.0, 1.0), + clamp(jsNumber(gr) / maxValue, 0.0, 1.0), + clamp(jsNumber(b) / maxValue, 0.0, 1.0), + if (a.isNotEmpty()) parseAlpha(jsNumber(a), ap) else 1.0, + ) + return if (rgba.none { it.isNaN() }) rgba else null + } + + // hsl(120 50% 80%), hsla(120deg,50%,80%,.9), hsl(12e1 50% 80% / 90%) + val hslMatch = HSL_REGEX.matchEntire(input) ?: return null + val g = hslMatch.groupValues + val h = g[1] + val f1 = g[2] + val s = g[3] + val f2 = g[4] + val l = g[5] + val f3 = g[6] + val a = g[7] + val ap = g[8] + + val argFormat = (f1.ifEmpty { " " }) + (f2.ifEmpty { " " }) + f3 + if (argFormat !in VALID_ARG_FORMATS) return null + + val hsla = doubleArrayOf( + jsNumber(h), + clamp(jsNumber(s), 0.0, 100.0), + clamp(jsNumber(l), 0.0, 100.0), + if (a.isNotEmpty()) parseAlpha(jsNumber(a), ap) else 1.0, + ) + return if (hsla.none { it.isNaN() }) hslToRgb(hsla) else null + } + + private val VALID_ARG_FORMATS = setOf(" ", " /", ",,", ",,,") + + private val HEX_REGEX = Regex("^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$") + + private val RGB_REGEX = Regex( + """^rgba?\(\s*([\de.+-]+)(%)?(?:\s+|\s*(,)\s*)([\de.+-]+)(%)?(?:\s+|\s*(,)\s*)([\de.+-]+)(%)?(?:\s*([,/])\s*([\de.+-]+)(%)?)?\s*\)$""" + ) + + private val HSL_REGEX = Regex( + """^hsla?\(\s*([\de.+-]+)(?:deg)?(?:\s+|\s*(,)\s*)([\de.+-]+)%(?:\s+|\s*(,)\s*)([\de.+-]+)%(?:\s*([,/])\s*([\de.+-]+)(%)?)?\s*\)$""" + ) + + /** `#f0c` expands each digit, so the slice is padded with itself before parsing. */ + private fun parseHex(hex: String): Double { + val padded = if (hex.length == 1) hex + hex else hex + return (padded.toIntOrNull(16) ?: return Double.NaN) / 255.0 + } + + private fun parseAlpha(a: Double, asPercentage: String): Double = + clamp(if (asPercentage.isNotEmpty()) a / 100.0 else a, 0.0, 1.0) + + private fun clamp(n: Double, minValue: Double, maxValue: Double): Double = min(max(minValue, n), maxValue) + + /** + * The numeric part of the regexes is loose, so a match may still not be a valid number. + * Mirrors JavaScript's unary `+`: anything unparseable becomes NaN and is rejected. + */ + private fun jsNumber(s: String): Double = s.toDoubleOrNull() ?: Double.NaN + + // 147 CSS Color 4 named colors + private val namedColors: Map = mapOf( + "aliceblue" to intArrayOf(240, 248, 255), + "antiquewhite" to intArrayOf(250, 235, 215), + "aqua" to intArrayOf(0, 255, 255), + "aquamarine" to intArrayOf(127, 255, 212), + "azure" to intArrayOf(240, 255, 255), + "beige" to intArrayOf(245, 245, 220), + "bisque" to intArrayOf(255, 228, 196), + "black" to intArrayOf(0, 0, 0), + "blanchedalmond" to intArrayOf(255, 235, 205), + "blue" to intArrayOf(0, 0, 255), + "blueviolet" to intArrayOf(138, 43, 226), + "brown" to intArrayOf(165, 42, 42), + "burlywood" to intArrayOf(222, 184, 135), + "cadetblue" to intArrayOf(95, 158, 160), + "chartreuse" to intArrayOf(127, 255, 0), + "chocolate" to intArrayOf(210, 105, 30), + "coral" to intArrayOf(255, 127, 80), + "cornflowerblue" to intArrayOf(100, 149, 237), + "cornsilk" to intArrayOf(255, 248, 220), + "crimson" to intArrayOf(220, 20, 60), + "cyan" to intArrayOf(0, 255, 255), + "darkblue" to intArrayOf(0, 0, 139), + "darkcyan" to intArrayOf(0, 139, 139), + "darkgoldenrod" to intArrayOf(184, 134, 11), + "darkgray" to intArrayOf(169, 169, 169), + "darkgreen" to intArrayOf(0, 100, 0), + "darkgrey" to intArrayOf(169, 169, 169), + "darkkhaki" to intArrayOf(189, 183, 107), + "darkmagenta" to intArrayOf(139, 0, 139), + "darkolivegreen" to intArrayOf(85, 107, 47), + "darkorange" to intArrayOf(255, 140, 0), + "darkorchid" to intArrayOf(153, 50, 204), + "darkred" to intArrayOf(139, 0, 0), + "darksalmon" to intArrayOf(233, 150, 122), + "darkseagreen" to intArrayOf(143, 188, 143), + "darkslateblue" to intArrayOf(72, 61, 139), + "darkslategray" to intArrayOf(47, 79, 79), + "darkslategrey" to intArrayOf(47, 79, 79), + "darkturquoise" to intArrayOf(0, 206, 209), + "darkviolet" to intArrayOf(148, 0, 211), + "deeppink" to intArrayOf(255, 20, 147), + "deepskyblue" to intArrayOf(0, 191, 255), + "dimgray" to intArrayOf(105, 105, 105), + "dimgrey" to intArrayOf(105, 105, 105), + "dodgerblue" to intArrayOf(30, 144, 255), + "firebrick" to intArrayOf(178, 34, 34), + "floralwhite" to intArrayOf(255, 250, 240), + "forestgreen" to intArrayOf(34, 139, 34), + "fuchsia" to intArrayOf(255, 0, 255), + "gainsboro" to intArrayOf(220, 220, 220), + "ghostwhite" to intArrayOf(248, 248, 255), + "gold" to intArrayOf(255, 215, 0), + "goldenrod" to intArrayOf(218, 165, 32), + "gray" to intArrayOf(128, 128, 128), + "green" to intArrayOf(0, 128, 0), + "greenyellow" to intArrayOf(173, 255, 47), + "grey" to intArrayOf(128, 128, 128), + "honeydew" to intArrayOf(240, 255, 240), + "hotpink" to intArrayOf(255, 105, 180), + "indianred" to intArrayOf(205, 92, 92), + "indigo" to intArrayOf(75, 0, 130), + "ivory" to intArrayOf(255, 255, 240), + "khaki" to intArrayOf(240, 230, 140), + "lavender" to intArrayOf(230, 230, 250), + "lavenderblush" to intArrayOf(255, 240, 245), + "lawngreen" to intArrayOf(124, 252, 0), + "lemonchiffon" to intArrayOf(255, 250, 205), + "lightblue" to intArrayOf(173, 216, 230), + "lightcoral" to intArrayOf(240, 128, 128), + "lightcyan" to intArrayOf(224, 255, 255), + "lightgoldenrodyellow" to intArrayOf(250, 250, 210), + "lightgray" to intArrayOf(211, 211, 211), + "lightgreen" to intArrayOf(144, 238, 144), + "lightgrey" to intArrayOf(211, 211, 211), + "lightpink" to intArrayOf(255, 182, 193), + "lightsalmon" to intArrayOf(255, 160, 122), + "lightseagreen" to intArrayOf(32, 178, 170), + "lightskyblue" to intArrayOf(135, 206, 250), + "lightslategray" to intArrayOf(119, 136, 153), + "lightslategrey" to intArrayOf(119, 136, 153), + "lightsteelblue" to intArrayOf(176, 196, 222), + "lightyellow" to intArrayOf(255, 255, 224), + "lime" to intArrayOf(0, 255, 0), + "limegreen" to intArrayOf(50, 205, 50), + "linen" to intArrayOf(250, 240, 230), + "magenta" to intArrayOf(255, 0, 255), + "maroon" to intArrayOf(128, 0, 0), + "mediumaquamarine" to intArrayOf(102, 205, 170), + "mediumblue" to intArrayOf(0, 0, 205), + "mediumorchid" to intArrayOf(186, 85, 211), + "mediumpurple" to intArrayOf(147, 112, 219), + "mediumseagreen" to intArrayOf(60, 179, 113), + "mediumslateblue" to intArrayOf(123, 104, 238), + "mediumspringgreen" to intArrayOf(0, 250, 154), + "mediumturquoise" to intArrayOf(72, 209, 204), + "mediumvioletred" to intArrayOf(199, 21, 133), + "midnightblue" to intArrayOf(25, 25, 112), + "mintcream" to intArrayOf(245, 255, 250), + "mistyrose" to intArrayOf(255, 228, 225), + "moccasin" to intArrayOf(255, 228, 181), + "navajowhite" to intArrayOf(255, 222, 173), + "navy" to intArrayOf(0, 0, 128), + "oldlace" to intArrayOf(253, 245, 230), + "olive" to intArrayOf(128, 128, 0), + "olivedrab" to intArrayOf(107, 142, 35), + "orange" to intArrayOf(255, 165, 0), + "orangered" to intArrayOf(255, 69, 0), + "orchid" to intArrayOf(218, 112, 214), + "palegoldenrod" to intArrayOf(238, 232, 170), + "palegreen" to intArrayOf(152, 251, 152), + "paleturquoise" to intArrayOf(175, 238, 238), + "palevioletred" to intArrayOf(219, 112, 147), + "papayawhip" to intArrayOf(255, 239, 213), + "peachpuff" to intArrayOf(255, 218, 185), + "peru" to intArrayOf(205, 133, 63), + "pink" to intArrayOf(255, 192, 203), + "plum" to intArrayOf(221, 160, 221), + "powderblue" to intArrayOf(176, 224, 230), + "purple" to intArrayOf(128, 0, 128), + "rebeccapurple" to intArrayOf(102, 51, 153), + "red" to intArrayOf(255, 0, 0), + "rosybrown" to intArrayOf(188, 143, 143), + "royalblue" to intArrayOf(65, 105, 225), + "saddlebrown" to intArrayOf(139, 69, 19), + "salmon" to intArrayOf(250, 128, 114), + "sandybrown" to intArrayOf(244, 164, 96), + "seagreen" to intArrayOf(46, 139, 87), + "seashell" to intArrayOf(255, 245, 238), + "sienna" to intArrayOf(160, 82, 45), + "silver" to intArrayOf(192, 192, 192), + "skyblue" to intArrayOf(135, 206, 235), + "slateblue" to intArrayOf(106, 90, 205), + "slategray" to intArrayOf(112, 128, 144), + "slategrey" to intArrayOf(112, 128, 144), + "snow" to intArrayOf(255, 250, 250), + "springgreen" to intArrayOf(0, 255, 127), + "steelblue" to intArrayOf(70, 130, 180), + "tan" to intArrayOf(210, 180, 140), + "teal" to intArrayOf(0, 128, 128), + "thistle" to intArrayOf(216, 191, 216), + "tomato" to intArrayOf(255, 99, 71), + "turquoise" to intArrayOf(64, 224, 208), + "violet" to intArrayOf(238, 130, 238), + "wheat" to intArrayOf(245, 222, 179), + "white" to intArrayOf(255, 255, 255), + "whitesmoke" to intArrayOf(245, 245, 245), + "yellow" to intArrayOf(255, 255, 0), + ) +} + +/** `[h, s, l, alpha]` with hue in degrees, saturation and lightness as percentages. */ +private fun hslToRgb(hsl: DoubleArray): RgbColor { + var h = hsl[0] % 360.0 + if (h < 0) h += 360.0 + val s = hsl[1] / 100.0 + val l = hsl[2] / 100.0 + + fun f(n: Double): Double { + val k = (n + h / 30.0) % 12.0 + val a = s * min(l, 1 - l) + return l - a * max(-1.0, min(min(k - 3, 9 - k), 1.0)) + } + + return doubleArrayOf(f(0.0), f(8.0), f(4.0), hsl[3]) +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Assertion.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Assertion.kt new file mode 100644 index 00000000..cb2fa4d0 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Assertion.kt @@ -0,0 +1,117 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.BooleanType +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ObjectType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.RuntimeError +import ovh.plrapps.mapcompose.vector.spec.style.expression.StringType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.array +import ovh.plrapps.mapcompose.vector.spec.style.expression.checkSubtype +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeOf +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeToString + +/** + * The `string` / `number` / `boolean` / `object` / `array` assertions. + * + * Ported from `maplibre-style-spec/src/expression/definitions/assertion.ts`. The parser also + * inserts these implicitly, which is what lets a `["get", …]` of unknown type be used where a + * number is expected. + */ +class Assertion( + override val type: ExprType, + val args: List, + val key: String, +) : Expression { + + override fun evaluate(ctx: EvaluationContext): Any? { + for (i in args.indices) { + val value = args[i].evaluate(ctx) + val error = checkSubtype(type, typeOf(value)) + if (error == null) { + return value + } else if (i == args.size - 1) { + throw RuntimeError( + "Expected value to be of type ${typeToString(type)}, " + + "but found ${typeToString(typeOf(value))} instead.", + key, + ) + } + } + throw RuntimeError("Expected at least one argument.", key) + } + + override fun eachChild(fn: (Expression) -> Unit) = args.forEach(fn) + + override fun outputDefined(): Boolean = args.all { it.outputDefined() } + + companion object { + private val types: Map = mapOf( + "string" to StringType, + "number" to NumberType, + "boolean" to BooleanType, + "object" to ObjectType, + ) + + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size < 2) return context.error("Expected at least one argument.") + + var i = 1 + val type: ExprType + + when (val name = args[0] as? String) { + "array" -> { + val itemType: ExprType + if (args.size > 2) { + val t = args[1] + if (t !is String || t !in types || t == "object") { + return context.error( + "The item type argument of \"array\" must be one of string, number, boolean", + 1, + ) + } + itemType = types.getValue(t) + i++ + } else { + itemType = ValueType + } + + var n: Int? = null + if (args.size > 3) { + val lengthArg = args[2] + if (lengthArg != null && + (lengthArg !is Number || lengthArg.toDouble() < 0 || + lengthArg.toDouble() != kotlin.math.floor(lengthArg.toDouble())) + ) { + return context.error( + "The length argument to \"array\" must be a positive integer literal", + 2, + ) + } + n = (lengthArg as? Number)?.toInt() + i++ + } + + type = array(itemType, n) + } + + else -> { + type = types[name] ?: return context.error("Types doesn't contain name = $name") + } + } + + val parsed = mutableListOf() + while (i < args.size) { + val input = context.parse(args[i], i, ValueType) ?: return null + parsed.add(input) + i++ + } + + return Assertion(type, parsed, context.key) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/At.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/At.kt new file mode 100644 index 00000000..c215f3ac --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/At.kt @@ -0,0 +1,59 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.ArrayType +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.RuntimeError +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.array +import ovh.plrapps.mapcompose.vector.spec.style.expression.formatNumber +import kotlin.math.floor + +/** Ported from `maplibre-style-spec/src/expression/definitions/at.ts`. */ +class At( + override val type: ExprType, + val index: Expression, + val input: Expression, + val key: String, +) : Expression { + + override fun evaluate(ctx: EvaluationContext): Any? { + val i = (index.evaluate(ctx) as Number).toDouble() + val arr = input.evaluate(ctx) as List<*> + + if (i < 0) throw RuntimeError("Array index out of bounds: ${formatNumber(i)} < 0.", key) + if (i >= arr.size) { + throw RuntimeError("Array index out of bounds: ${formatNumber(i)} > ${arr.size - 1}.", key) + } + if (i != floor(i)) { + throw RuntimeError("Array index must be an integer, but found ${formatNumber(i)} instead.", key) + } + + return arr[i.toInt()] + } + + override fun eachChild(fn: (Expression) -> Unit) { + fn(index) + fn(input) + } + + override fun outputDefined(): Boolean = false + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size != 3) { + return context.error("Expected 2 arguments, but found ${args.size - 1} instead.") + } + + val index = context.parse(args[1], 1, NumberType) + val input = context.parse(args[2], 2, array(context.expectedType ?: ValueType)) + if (index == null || input == null) return null + + val t = input.type as ArrayType + return At(t.itemType, index, input, context.key) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Case.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Case.kt new file mode 100644 index 00000000..2873548d --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Case.kt @@ -0,0 +1,67 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.BooleanType +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType + +/** Ported from `maplibre-style-spec/src/expression/definitions/case.ts`. */ +class Case( + override val type: ExprType, + val branches: List>, + val otherwise: Expression, +) : Expression { + + override fun evaluate(ctx: EvaluationContext): Any? { + for ((test, expression) in branches) { + if (test.evaluate(ctx) == true) { + return expression.evaluate(ctx) + } + } + return otherwise.evaluate(ctx) + } + + override fun eachChild(fn: (Expression) -> Unit) { + for ((test, expression) in branches) { + fn(test) + fn(expression) + } + fn(otherwise) + } + + override fun outputDefined(): Boolean = + branches.all { it.second.outputDefined() } && otherwise.outputDefined() + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size < 4) { + return context.error("Expected at least 3 arguments, but found only ${args.size - 1}.") + } + if (args.size % 2 != 0) { + return context.error("Expected an odd number of arguments.") + } + + var outputType: ExprType? = null + if (context.expectedType != null && context.expectedType != ValueType) { + outputType = context.expectedType + } + + val branches = mutableListOf>() + var i = 1 + while (i < args.size - 1) { + val test = context.parse(args[i], i, BooleanType) ?: return null + val result = context.parse(args[i + 1], i + 1, outputType) ?: return null + branches.add(test to result) + if (outputType == null) outputType = result.type + i += 2 + } + + val otherwise = context.parse(args[args.size - 1], args.size - 1, outputType) ?: return null + + val inferred = outputType ?: error("Can't infer output type") + return Case(inferred, branches, otherwise) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Coalesce.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Coalesce.kt new file mode 100644 index 00000000..e19e2b6c --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Coalesce.kt @@ -0,0 +1,70 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.checkSubtype +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.ResolvedImage + +/** Ported from `maplibre-style-spec/src/expression/definitions/coalesce.ts`. */ +class Coalesce(override val type: ExprType, val args: List) : Expression { + + override fun evaluate(ctx: EvaluationContext): Any? { + var result: Any? = null + var argCount = 0 + var requestedImageName: String? = null + for (arg in args) { + argCount++ + result = arg.evaluate(ctx) + // Keep track of the first requested image: if coalesce can't find a valid one we return + // its name so the host can report a missing image. + if (result is ResolvedImage && !result.available) { + if (requestedImageName == null) requestedImageName = result.name + result = null + if (argCount == args.size) result = requestedImageName + } + if (result != null) break + } + return result + } + + override fun eachChild(fn: (Expression) -> Unit) = args.forEach(fn) + + override fun outputDefined(): Boolean = args.all { it.outputDefined() } + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size < 2) return context.error("Expected at least one argument.") + + var outputType: ExprType? = null + val expectedType = context.expectedType + if (expectedType != null && expectedType != ValueType) { + outputType = expectedType + } + val parsedArgs = mutableListOf() + + for (arg in args.drop(1)) { + val parsed = context.parse( + arg, + 1 + parsedArgs.size, + outputType, + typeAnnotation = ParsingContext.TypeAnnotation.OMIT, + ) ?: return null + if (outputType == null) outputType = parsed.type + parsedArgs.add(parsed) + } + val inferred = outputType ?: error("No output type") + + // Arguments are parsed without inferred type annotation so that they don't produce a + // runtime error for `null` input, which would preempt the null-coalescing behaviour. + // If any argument would have needed an annotation, the enclosing coalesce is wrapped + // instead. + val needsAnnotation = expectedType != null && + parsedArgs.any { checkSubtype(expectedType, it.type) != null } + + return if (needsAnnotation) Coalesce(ValueType, parsedArgs) else Coalesce(inferred, parsedArgs) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Coercion.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Coercion.kt new file mode 100644 index 00000000..ad5e6bb8 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Coercion.kt @@ -0,0 +1,136 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import androidx.compose.ui.graphics.Color +import ovh.plrapps.mapcompose.vector.spec.style.expression.BooleanType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ColorType +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.FormattedType +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ResolvedImageType +import ovh.plrapps.mapcompose.vector.spec.style.expression.RuntimeError +import ovh.plrapps.mapcompose.vector.spec.style.expression.StringType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.jsToNumber +import ovh.plrapps.mapcompose.vector.spec.style.expression.jsTruthy +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.Formatted +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.ResolvedImage +import ovh.plrapps.mapcompose.vector.spec.style.expression.validateRGBA +import ovh.plrapps.mapcompose.vector.spec.style.expression.valueToJsonString +import ovh.plrapps.mapcompose.vector.spec.style.expression.valueToString + +/** + * Special form for error-coalescing coercion expressions `to-number`, `to-color`. + * + * Ported from `maplibre-style-spec/src/expression/definitions/coercion.ts`. Since these coercions + * can fail at runtime they accept multiple arguments, evaluating one at a time until one succeeds. + * The parser also inserts these implicitly for color- and formatted-typed properties. + */ +class Coercion( + override val type: ExprType, + val args: List, + val key: String, +) : Expression { + + override fun evaluate(ctx: EvaluationContext): Any? = when (type) { + BooleanType -> jsTruthy(args[0].evaluate(ctx)) + + ColorType -> { + var input: Any? = null + var error: String? = null + var result: Color? = null + for (arg in args) { + input = arg.evaluate(ctx) + error = null + if (input is Color) { + result = input + break + } else if (input is String) { + val c = ctx.parseColor(input) + if (c != null) { + result = c + break + } + } else if (input is List<*>) { + error = if (input.size < 3 || input.size > 4) { + "Invalid rgba value ${valueToJsonString(input)}: expected an array " + + "containing either three or four numeric values." + } else { + validateRGBA(input[0], input[1], input[2], input.getOrNull(3)) + } + if (error == null) { + result = Color( + red = ((input[0] as Number).toDouble() / 255.0).toFloat(), + green = ((input[1] as Number).toDouble() / 255.0).toFloat(), + blue = ((input[2] as Number).toDouble() / 255.0).toFloat(), + alpha = ((input.getOrNull(3) as? Number)?.toDouble() ?: 1.0).toFloat(), + ) + break + } + } + } + result ?: throw RuntimeError( + error ?: "Could not parse color from value '${ + if (input is String) input else valueToJsonString(input) + }'", + key, + ) + } + + NumberType -> { + var value: Any? = null + var result: Double? = null + for (arg in args) { + value = arg.evaluate(ctx) + if (value == null) { + result = 0.0 + break + } + val num = jsToNumber(value) + if (num.isNaN()) continue + result = num + break + } + result ?: throw RuntimeError("Could not convert ${valueToJsonString(value)} to number.", key) + } + + FormattedType -> Formatted.fromString(valueToString(args[0].evaluate(ctx))) + + ResolvedImageType -> ResolvedImage.fromString(valueToString(args[0].evaluate(ctx))) + + else -> valueToString(args[0].evaluate(ctx)) + } + + override fun eachChild(fn: (Expression) -> Unit) = args.forEach(fn) + + override fun outputDefined(): Boolean = args.all { it.outputDefined() } + + companion object { + private val types: Map = mapOf( + "to-boolean" to BooleanType, + "to-color" to ColorType, + "to-number" to NumberType, + "to-string" to StringType, + ) + + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size < 2) return context.error("Expected at least one argument.") + + val name = args[0] as? String + val type = types[name] ?: return context.error("Can't parse $name as it is not part of the known types") + if ((name == "to-boolean" || name == "to-string") && args.size != 2) { + return context.error("Expected one argument.") + } + + val parsed = mutableListOf() + for (i in 1 until args.size) { + val input = context.parse(args[i], i, ValueType) ?: return null + parsed.add(input) + } + + return Coercion(type, parsed, context.key) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Collator.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Collator.kt new file mode 100644 index 00000000..fd970a80 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Collator.kt @@ -0,0 +1,60 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.BooleanType +import ovh.plrapps.mapcompose.vector.spec.style.expression.CollatorType +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.StringType +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.Collator as CollatorValue + +/** Ported from `maplibre-style-spec/src/expression/definitions/collator.ts`. */ +class Collator( + val caseSensitive: Expression, + val diacriticSensitive: Expression, + val locale: Expression?, +) : Expression { + + override val type: ExprType = CollatorType + + override fun evaluate(ctx: EvaluationContext): Any = CollatorValue( + caseSensitive = caseSensitive.evaluate(ctx) as Boolean, + diacriticSensitive = diacriticSensitive.evaluate(ctx) as Boolean, + locale = locale?.evaluate(ctx) as String?, + ) + + override fun eachChild(fn: (Expression) -> Unit) { + fn(caseSensitive) + fn(diacriticSensitive) + locale?.let(fn) + } + + // Technically the set of possible outputs is the combinatoric set of Collators produced by all + // possible outputs of locale/caseSensitive/diacriticSensitive, but the comparison operators + // ignore a Collator's possible outputs anyway. + override fun outputDefined(): Boolean = false + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size != 2) return context.error("Expected one argument.") + + val options = args[1] + if (options !is Map<*, *>) { + return context.error("Collator options argument must be an object.") + } + + val caseSensitive = context.parse(options["case-sensitive"] ?: false, 1, BooleanType) + ?: return null + val diacriticSensitive = context.parse(options["diacritic-sensitive"] ?: false, 1, BooleanType) + ?: return null + + var locale: Expression? = null + if (options["locale"] != null) { + locale = context.parse(options["locale"], 1, StringType) ?: return null + } + + return Collator(caseSensitive, diacriticSensitive, locale) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Comparison.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Comparison.kt new file mode 100644 index 00000000..26a8963f --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Comparison.kt @@ -0,0 +1,179 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.BooleanType +import ovh.plrapps.mapcompose.vector.spec.style.expression.CollatorType +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.NullType +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.RuntimeError +import ovh.plrapps.mapcompose.vector.spec.style.expression.StringType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.jsStrictEqual +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeOf +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeToString +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.Collator as CollatorValue + +/** + * Special form for the comparison operators, implementing the signatures: + * - `(T, T, ?Collator) => boolean` + * - `(T, value, ?Collator) => boolean` + * - `(value, T, ?Collator) => boolean` + * + * For inequalities `T` must be `value`, `string` or `number`. For `==`/`!=` it may also be + * `boolean` or `null`. + * + * Equality semantics are JavaScript's strict equality: when the argument types don't match, `==` + * evaluates to false and `!=` to true. When types don't match in an *ordering* comparison, a + * runtime error is thrown. + * + * Ported from `maplibre-style-spec/src/expression/definitions/comparison.ts`. + */ +class Comparison( + val op: String, + val lhs: Expression, + val rhs: Expression, + val key: String, + val collator: Expression? = null, +) : Expression { + + override val type: ExprType = BooleanType + + private val isOrderComparison: Boolean = op != "==" && op != "!=" + + private val hasUntypedArgument: Boolean = lhs.type == ValueType || rhs.type == ValueType + + override fun evaluate(ctx: EvaluationContext): Any { + val l = lhs.evaluate(ctx) + val r = rhs.evaluate(ctx) + + if (isOrderComparison && hasUntypedArgument) { + val lt = typeOf(l) + val rt = typeOf(r) + if (lt.kind != rt.kind || !(lt == StringType || lt == NumberType)) { + throw RuntimeError( + "Expected arguments for \"$op\" to be (string, string) or (number, number), " + + "but found (${lt.kind}, ${rt.kind}) instead.", + key, + ) + } + } + + if (collator != null && !isOrderComparison && hasUntypedArgument) { + if (typeOf(l) != StringType || typeOf(r) != StringType) { + return compareBasic(l, r) + } + } + + return if (collator != null) { + compareWithCollator(l, r, collator.evaluate(ctx) as CollatorValue) + } else { + compareBasic(l, r) + } + } + + private fun compareBasic(a: Any?, b: Any?): Boolean = when (op) { + "==" -> jsStrictEqual(a, b) + "!=" -> !jsStrictEqual(a, b) + else -> { + val cmp = orderCompare(a, b) + when (op) { + "<" -> cmp < 0 + ">" -> cmp > 0 + "<=" -> cmp <= 0 + else -> cmp >= 0 + } + } + } + + private fun compareWithCollator(a: Any?, b: Any?, c: CollatorValue): Boolean { + val cmp = c.compare(a as String, b as String) + return when (op) { + "==" -> cmp == 0 + "!=" -> cmp != 0 + "<" -> cmp < 0 + ">" -> cmp > 0 + "<=" -> cmp <= 0 + else -> cmp >= 0 + } + } + + private fun orderCompare(a: Any?, b: Any?): Int = when { + a is Number && b is Number -> a.toDouble().compareTo(b.toDouble()) + a is String && b is String -> a.compareTo(b) + else -> throw RuntimeError( + "Expected arguments for \"$op\" to be (string, string) or (number, number), " + + "but found (${typeOf(a).kind}, ${typeOf(b).kind}) instead.", + key, + ) + } + + override fun eachChild(fn: (Expression) -> Unit) { + fn(lhs) + fn(rhs) + collator?.let(fn) + } + + override fun outputDefined(): Boolean = true + + companion object { + private fun isComparableType(op: String, type: ExprType): Boolean = if (op == "==" || op == "!=") { + type == BooleanType || type == StringType || type == NumberType || + type == NullType || type == ValueType + } else { + type == StringType || type == NumberType || type == ValueType + } + + fun parser(op: String): (List, ParsingContext) -> Expression? = { args, context -> + parse(op, args, context) + } + + private fun parse(op: String, args: List, context: ParsingContext): Expression? { + val isOrderComparison = op != "==" && op != "!=" + + if (args.size != 3 && args.size != 4) { + return context.error("Expected two or three arguments.") + } + + var lhs = context.parse(args[1], 1, ValueType) ?: return null + if (!isComparableType(op, lhs.type)) { + return context.concat(1) + .error("\"$op\" comparisons are not supported for type '${typeToString(lhs.type)}'.") + } + var rhs = context.parse(args[2], 2, ValueType) ?: return null + if (!isComparableType(op, rhs.type)) { + return context.concat(2) + .error("\"$op\" comparisons are not supported for type '${typeToString(rhs.type)}'.") + } + + if (lhs.type.kind != rhs.type.kind && lhs.type != ValueType && rhs.type != ValueType) { + return context.error( + "Cannot compare types '${typeToString(lhs.type)}' and '${typeToString(rhs.type)}'." + ) + } + + if (isOrderComparison) { + // Typing rules specific to the less/greater-than operators. + if (lhs.type == ValueType && rhs.type != ValueType) { + lhs = Assertion(rhs.type, listOf(lhs), context.key) + } else if (lhs.type != ValueType && rhs.type == ValueType) { + rhs = Assertion(lhs.type, listOf(rhs), context.key) + } + } + + var collator: Expression? = null + if (args.size == 4) { + if (lhs.type != StringType && rhs.type != StringType && + lhs.type != ValueType && rhs.type != ValueType + ) { + return context.error("Cannot use collator to compare non-string types.") + } + collator = context.parse(args[3], 3, CollatorType) ?: return null + } + + return Comparison(op, lhs, rhs, context.key, collator) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Distance.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Distance.kt new file mode 100644 index 00000000..0b03e079 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Distance.kt @@ -0,0 +1,578 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.CanonicalTileId +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.Point2D +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.BBox +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.CheapRuler +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.Coord +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.TinyQueue +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.boxWithinBox +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.classifyRings +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.getLngLatFromTileCoord +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.newBBox +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.pointWithinPolygon +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.segmentIntersectSegment +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.updateBBox +import ovh.plrapps.mapcompose.vector.spec.style.expression.isValue +import kotlin.math.floor +import kotlin.math.min + +/** + * Distance in metres from the feature to a GeoJSON geometry. + * + * Ported from `maplibre-style-spec/src/expression/definitions/distance.ts`. The divide-and-conquer + * search (a priority queue over index ranges, pruned by bounding-box distance) is reproduced as-is, + * including its `MinPointsSize` / `MinLinePointsSize` brute-force thresholds. + */ +class Distance(val geojson: Any?, private val geometries: List) : Expression { + + override val type: ExprType = NumberType + + /** A GeoJSON geometry flattened to its non-`Multi` form. */ + data class SimpleGeometry(val type: String, val coordinates: Any?) + + override fun evaluate(ctx: EvaluationContext): Any { + val canonical = ctx.canonicalID() ?: return Double.NaN + if (ctx.geometry().isEmpty()) return Double.NaN + + return when (ctx.geometryType()) { + "Point" -> pointToGeometryDistance(ctx.geometry(), canonical) + "LineString" -> lineStringToGeometryDistance(ctx.geometry(), canonical) + "Polygon" -> polygonToGeometryDistance(ctx.geometry(), canonical) + else -> Double.NaN + } + } + + override fun eachChild(fn: (Expression) -> Unit) = Unit + + override fun outputDefined(): Boolean = true + + // region top-level dispatch + + private fun pointToGeometryDistance(tilePoints: List>, canonical: CanonicalTileId): Double { + val pointPosition = tilePoints.flatten().map { toLngLat(it, canonical) } + if (tilePoints.isEmpty()) return Double.NaN + val ruler = CheapRuler(pointPosition[0][1]) + var dist = Double.POSITIVE_INFINITY + for (geometry in geometries) { + dist = min(dist, distanceToPointSet(pointPosition, isLine = false, geometry, ruler, dist)) + if (dist == 0.0) return dist + } + return dist + } + + private fun lineStringToGeometryDistance(tileLine: List>, canonical: CanonicalTileId): Double { + val linePositions = tileLine.flatten().map { toLngLat(it, canonical) } + if (tileLine.isEmpty()) return Double.NaN + val ruler = CheapRuler(linePositions[0][1]) + var dist = Double.POSITIVE_INFINITY + for (geometry in geometries) { + dist = min(dist, distanceToPointSet(linePositions, isLine = true, geometry, ruler, dist)) + if (dist == 0.0) return dist + } + return dist + } + + private fun distanceToPointSet( + positions: List, + isLine: Boolean, + geometry: SimpleGeometry, + ruler: CheapRuler, + currentMiniDist: Double, + ): Double = when (geometry.type) { + "Point" -> pointSetToPointSetDistance( + positions, isLine, listOf(asCoord(geometry.coordinates)), false, ruler, currentMiniDist, + ) + + "LineString" -> pointSetToPointSetDistance( + positions, isLine, asLine(geometry.coordinates), true, ruler, currentMiniDist, + ) + + "Polygon" -> pointsToPolygonDistance( + positions, isLine, asPolygon(geometry.coordinates), ruler, currentMiniDist, + ) + + else -> Double.POSITIVE_INFINITY + } + + private fun polygonToGeometryDistance(tilePolygon: List>, canonical: CanonicalTileId): Double { + if (tilePolygon.isEmpty() || tilePolygon[0].isEmpty()) return Double.NaN + + val polygons = classifyRings(tilePolygon).map { polygon -> + polygon.map { ring -> ring.map { toLngLat(it, canonical) } } + } + if (polygons.isEmpty() || polygons[0].isEmpty() || polygons[0][0].isEmpty()) return Double.NaN + + val ruler = CheapRuler(polygons[0][0][0][1]) + var dist = Double.POSITIVE_INFINITY + for (geometry in geometries) { + for (polygon in polygons) { + dist = when (geometry.type) { + "Point" -> min( + dist, + pointsToPolygonDistance( + listOf(asCoord(geometry.coordinates)), false, polygon, ruler, dist, + ), + ) + + "LineString" -> min( + dist, + pointsToPolygonDistance(asLine(geometry.coordinates), true, polygon, ruler, dist), + ) + + "Polygon" -> min( + dist, + polygonToPolygonDistance(polygon, asPolygon(geometry.coordinates), ruler, dist), + ) + + else -> dist + } + if (dist == 0.0) return dist + } + } + return dist + } + + private fun toLngLat(p: Point2D, canonical: CanonicalTileId): Coord { + val lngLat = getLngLatFromTileCoord(doubleArrayOf(p.x, p.y), canonical) + return doubleArrayOf(lngLat[0], lngLat[1]) + } + + // endregion + + companion object { + private const val MIN_POINTS_SIZE = 100 + private const val MIN_LINE_POINTS_SIZE = 50 + + /** An inclusive `[start, end]` index range into a point list. */ + private class IndexRange(val start: Int, val end: Int) + + private class DistPair(val dist: Double, val range1: IndexRange, val range2: IndexRange) + + // The queue is deliberately ordered so the pair with the *biggest* bbox distance pops + // first, matching upstream's comparator. + private val DIST_PAIR_COMPARATOR = Comparator { a, b -> b.dist.compareTo(a.dist) } + + private fun getRangeSize(range: IndexRange): Int = range.end - range.start + 1 + + private fun isRangeSafe(range: IndexRange, threshold: Int): Boolean = + range.end >= range.start && range.end < threshold + + private fun splitRange(range: IndexRange, isLine: Boolean): Pair { + if (range.start > range.end) return null to null + val size = getRangeSize(range) + if (isLine) { + if (size == 2) return range to null + val size1 = floor(size / 2.0).toInt() + return IndexRange(range.start, range.start + size1) to + IndexRange(range.start + size1, range.end) + } + if (size == 1) return range to null + val size1 = floor(size / 2.0).toInt() - 1 + return IndexRange(range.start, range.start + size1) to + IndexRange(range.start + size1 + 1, range.end) + } + + private fun getBBox(coords: List, range: IndexRange): BBox { + if (!isRangeSafe(range, coords.size)) return newBBox() + val bbox = newBBox() + for (i in range.start..range.end) updateBBox(bbox, coords[i]) + return bbox + } + + private fun getPolygonBBox(polygon: List>): BBox { + val bbox = newBBox() + for (ring in polygon) for (coord in ring) updateBBox(bbox, coord) + return bbox + } + + private fun isValidBBox(bbox: BBox): Boolean = + bbox[0] != Double.NEGATIVE_INFINITY && bbox[1] != Double.NEGATIVE_INFINITY && + bbox[2] != Double.POSITIVE_INFINITY && bbox[3] != Double.POSITIVE_INFINITY + + /** + * Distance between two bounding boxes: the x and y gaps are turned into a single fake + * segment `(0,0) -> (dx,dy)` and measured with the ruler. Zero when the boxes overlap. + */ + private fun bboxToBBoxDistance(bbox1: BBox, bbox2: BBox, ruler: CheapRuler): Double { + if (!isValidBBox(bbox1) || !isValidBBox(bbox2)) return Double.NaN + var dx = 0.0 + var dy = 0.0 + if (bbox1[2] < bbox2[0]) dx = bbox2[0] - bbox1[2] + if (bbox1[0] > bbox2[2]) dx = bbox1[0] - bbox2[2] + if (bbox1[1] > bbox2[3]) dy = bbox1[1] - bbox2[3] + if (bbox1[3] < bbox2[1]) dy = bbox2[1] - bbox1[3] + return ruler.distance(doubleArrayOf(0.0, 0.0), doubleArrayOf(dx, dy)) + } + + private fun pointToLineDistance(point: Coord, line: List, ruler: CheapRuler): Double { + val nearestPoint = ruler.pointOnLine(line, point) + return ruler.distance(point, nearestPoint.point) + } + + private fun segmentToSegmentDistance( + p1: Coord, p2: Coord, q1: Coord, q2: Coord, ruler: CheapRuler, + ): Double { + val qLine = listOf(q1, q2) + val pLine = listOf(p1, p2) + val dist1 = min(pointToLineDistance(p1, qLine, ruler), pointToLineDistance(p2, qLine, ruler)) + val dist2 = min(pointToLineDistance(q1, pLine, ruler), pointToLineDistance(q2, pLine, ruler)) + return min(dist1, dist2) + } + + private fun lineToLineDistance( + line1: List, range1: IndexRange, + line2: List, range2: IndexRange, + ruler: CheapRuler, + ): Double { + if (!isRangeSafe(range1, line1.size) || !isRangeSafe(range2, line2.size)) { + return Double.POSITIVE_INFINITY + } + + var dist = Double.POSITIVE_INFINITY + for (i in range1.start until range1.end) { + val p1 = line1[i] + val p2 = line1[i + 1] + for (j in range2.start until range2.end) { + val q1 = line2[j] + val q2 = line2[j + 1] + if (segmentIntersectSegment(p1, p2, q1, q2)) return 0.0 + dist = min(dist, segmentToSegmentDistance(p1, p2, q1, q2, ruler)) + } + } + return dist + } + + private fun pointsToPointsDistance( + points1: List, range1: IndexRange, + points2: List, range2: IndexRange, + ruler: CheapRuler, + ): Double { + if (!isRangeSafe(range1, points1.size) || !isRangeSafe(range2, points2.size)) { + return Double.NaN + } + + var dist = Double.POSITIVE_INFINITY + for (i in range1.start..range1.end) { + for (j in range2.start..range2.end) { + dist = min(dist, ruler.distance(points1[i], points2[j])) + if (dist == 0.0) return dist + } + } + return dist + } + + private fun pointToPolygonDistance( + point: Coord, polygon: List>, ruler: CheapRuler, + ): Double { + if (pointWithinPolygon(point, polygon, trueIfOnBoundary = true)) return 0.0 + var dist = Double.POSITIVE_INFINITY + for (ring in polygon) { + val front = ring.first() + val back = ring.last() + if (!front.contentEquals(back)) { + dist = min(dist, pointToLineDistance(point, listOf(back, front), ruler)) + if (dist == 0.0) return dist + } + val nearestPoint = ruler.pointOnLine(ring, point) + dist = min(dist, ruler.distance(point, nearestPoint.point)) + if (dist == 0.0) return dist + } + return dist + } + + private fun lineToPolygonDistance( + line: List, range: IndexRange, polygon: List>, ruler: CheapRuler, + ): Double { + if (!isRangeSafe(range, line.size)) return Double.NaN + + for (i in range.start..range.end) { + if (pointWithinPolygon(line[i], polygon, trueIfOnBoundary = true)) return 0.0 + } + + var dist = Double.POSITIVE_INFINITY + for (i in range.start until range.end) { + val p1 = line[i] + val p2 = line[i + 1] + for (ring in polygon) { + var k = ring.size - 1 + for (j in ring.indices) { + val q1 = ring[k] + val q2 = ring[j] + if (segmentIntersectSegment(p1, p2, q1, q2)) return 0.0 + dist = min(dist, segmentToSegmentDistance(p1, p2, q1, q2, ruler)) + k = j + } + } + } + return dist + } + + private fun polygonIntersect(poly1: List>, poly2: List>): Boolean { + for (ring in poly1) { + for (point in ring) { + if (pointWithinPolygon(point, poly2, trueIfOnBoundary = true)) return true + } + } + return false + } + + private fun polygonToPolygonDistance( + polygon1: List>, + polygon2: List>, + ruler: CheapRuler, + currentMiniDist: Double = Double.POSITIVE_INFINITY, + ): Double { + val bbox1 = getPolygonBBox(polygon1) + val bbox2 = getPolygonBBox(polygon2) + if (currentMiniDist != Double.POSITIVE_INFINITY && + bboxToBBoxDistance(bbox1, bbox2, ruler) >= currentMiniDist + ) { + return currentMiniDist + } + + if (boxWithinBox(bbox1, bbox2)) { + if (polygonIntersect(polygon1, polygon2)) return 0.0 + } else if (polygonIntersect(polygon2, polygon1)) { + return 0.0 + } + + var dist = Double.POSITIVE_INFINITY + for (ring1 in polygon1) { + var l = ring1.size - 1 + for (i in ring1.indices) { + val p1 = ring1[l] + val p2 = ring1[i] + for (ring2 in polygon2) { + var k = ring2.size - 1 + for (j in ring2.indices) { + val q1 = ring2[k] + val q2 = ring2[j] + if (segmentIntersectSegment(p1, p2, q1, q2)) return 0.0 + dist = min(dist, segmentToSegmentDistance(p1, p2, q1, q2, ruler)) + k = j + } + } + l = i + } + } + return dist + } + + private fun updateQueue( + distQueue: TinyQueue, + miniDist: Double, + ruler: CheapRuler, + points: List, + polyBBox: BBox, + rangeA: IndexRange?, + ) { + if (rangeA == null) return + val tempDist = bboxToBBoxDistance(getBBox(points, rangeA), polyBBox, ruler) + if (tempDist < miniDist) { + distQueue.push(DistPair(tempDist, rangeA, IndexRange(0, 0))) + } + } + + private fun updateQueueTwoSets( + distQueue: TinyQueue, + miniDist: Double, + ruler: CheapRuler, + pointSet1: List, + pointSet2: List, + range1: IndexRange?, + range2: IndexRange?, + ) { + if (range1 == null || range2 == null) return + val tempDist = bboxToBBoxDistance( + getBBox(pointSet1, range1), getBBox(pointSet2, range2), ruler, + ) + if (tempDist < miniDist) { + distQueue.push(DistPair(tempDist, range1, range2)) + } + } + + /** Divide and conquer: O(n log n) instead of the O(n²) brute force. */ + private fun pointsToPolygonDistance( + points: List, + isLine: Boolean, + polygon: List>, + ruler: CheapRuler, + currentMiniDist: Double = Double.POSITIVE_INFINITY, + ): Double { + if (points.isEmpty() || polygon.isEmpty() || polygon[0].isEmpty()) return Double.NaN + + var miniDist = min(ruler.distance(points[0], polygon[0][0]), currentMiniDist) + if (miniDist == 0.0) return miniDist + + val distQueue = TinyQueue( + listOf(DistPair(0.0, IndexRange(0, points.size - 1), IndexRange(0, 0))), + DIST_PAIR_COMPARATOR, + ) + + val polyBBox = getPolygonBBox(polygon) + while (distQueue.size > 0) { + val distPair = distQueue.pop() ?: break + if (distPair.dist >= miniDist) continue + + val range = distPair.range1 + + // For relatively small sets, brute force directly. + val threshold = if (isLine) MIN_LINE_POINTS_SIZE else MIN_POINTS_SIZE + if (getRangeSize(range) <= threshold) { + if (!isRangeSafe(range, points.size)) return Double.NaN + if (isLine) { + val tempDist = lineToPolygonDistance(points, range, polygon, ruler) + if (tempDist.isNaN() || tempDist == 0.0) return tempDist + miniDist = min(miniDist, tempDist) + } else { + for (i in range.start..range.end) { + val tempDist = pointToPolygonDistance(points[i], polygon, ruler) + miniDist = min(miniDist, tempDist) + if (miniDist == 0.0) return 0.0 + } + } + } else { + val (a, b) = splitRange(range, isLine) + updateQueue(distQueue, miniDist, ruler, points, polyBBox, a) + updateQueue(distQueue, miniDist, ruler, points, polyBBox, b) + } + } + return miniDist + } + + private fun pointSetToPointSetDistance( + pointSet1: List, + isLine1: Boolean, + pointSet2: List, + isLine2: Boolean, + ruler: CheapRuler, + currentMiniDist: Double = Double.POSITIVE_INFINITY, + ): Double { + if (pointSet1.isEmpty() || pointSet2.isEmpty()) return Double.NaN + + var miniDist = min(currentMiniDist, ruler.distance(pointSet1[0], pointSet2[0])) + if (miniDist == 0.0) return miniDist + + val distQueue = TinyQueue( + listOf( + DistPair( + 0.0, + IndexRange(0, pointSet1.size - 1), + IndexRange(0, pointSet2.size - 1), + ) + ), + DIST_PAIR_COMPARATOR, + ) + + while (distQueue.size > 0) { + val distPair = distQueue.pop() ?: break + if (distPair.dist >= miniDist) continue + + val rangeA = distPair.range1 + val rangeB = distPair.range2 + val threshold1 = if (isLine1) MIN_LINE_POINTS_SIZE else MIN_POINTS_SIZE + val threshold2 = if (isLine2) MIN_LINE_POINTS_SIZE else MIN_POINTS_SIZE + + if (getRangeSize(rangeA) <= threshold1 && getRangeSize(rangeB) <= threshold2) { + if (!isRangeSafe(rangeA, pointSet1.size) && isRangeSafe(rangeB, pointSet2.size)) { + return Double.NaN + } + if (isLine1 && isLine2) { + miniDist = min(miniDist, lineToLineDistance(pointSet1, rangeA, pointSet2, rangeB, ruler)) + } else if (isLine1 && !isLine2) { + val subline = pointSet1.subList(rangeA.start, rangeA.end + 1) + for (i in rangeB.start..rangeB.end) { + miniDist = min(miniDist, pointToLineDistance(pointSet2[i], subline, ruler)) + if (miniDist == 0.0) return miniDist + } + } else if (!isLine1 && isLine2) { + val subline = pointSet2.subList(rangeB.start, rangeB.end + 1) + for (i in rangeA.start..rangeA.end) { + miniDist = min(miniDist, pointToLineDistance(pointSet1[i], subline, ruler)) + if (miniDist == 0.0) return miniDist + } + } else { + miniDist = min(miniDist, pointsToPointsDistance(pointSet1, rangeA, pointSet2, rangeB, ruler)) + } + } else { + val (a1, a2) = splitRange(rangeA, isLine1) + val (b1, b2) = splitRange(rangeB, isLine2) + updateQueueTwoSets(distQueue, miniDist, ruler, pointSet1, pointSet2, a1, b1) + updateQueueTwoSets(distQueue, miniDist, ruler, pointSet1, pointSet2, a1, b2) + updateQueueTwoSets(distQueue, miniDist, ruler, pointSet1, pointSet2, a2, b1) + updateQueueTwoSets(distQueue, miniDist, ruler, pointSet1, pointSet2, a2, b2) + } + } + return miniDist + } + + // region GeoJSON decoding + + private fun asCoord(coordinates: Any?): Coord { + val p = coordinates as? List<*> ?: return doubleArrayOf(0.0, 0.0) + return doubleArrayOf( + (p.getOrNull(0) as? Number)?.toDouble() ?: 0.0, + (p.getOrNull(1) as? Number)?.toDouble() ?: 0.0, + ) + } + + private fun asLine(coordinates: Any?): List = + (coordinates as? List<*>)?.map { asCoord(it) } ?: emptyList() + + private fun asPolygon(coordinates: Any?): List> = + (coordinates as? List<*>)?.map { asLine(it) } ?: emptyList() + + private fun toSimpleGeometry(geometry: Map<*, *>): List { + val coordinates = geometry["coordinates"] + return when (geometry["type"]) { + "MultiPolygon" -> (coordinates as? List<*>).orEmpty().map { SimpleGeometry("Polygon", it) } + "MultiLineString" -> (coordinates as? List<*>).orEmpty().map { SimpleGeometry("LineString", it) } + "MultiPoint" -> (coordinates as? List<*>).orEmpty().map { SimpleGeometry("Point", it) } + else -> listOf(SimpleGeometry(geometry["type"] as? String ?: "", coordinates)) + } + } + + // endregion + + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size != 2) { + return context.error( + "'distance' expression requires exactly one argument, but found ${args.size - 1} instead." + ) + } + if (isValue(args[1])) { + val geojson = args[1] as? Map<*, *> + if (geojson != null) { + when (geojson["type"]) { + "FeatureCollection" -> { + val features = (geojson["features"] as? List<*>).orEmpty() + val geometries = features.flatMap { feature -> + val geometry = (feature as? Map<*, *>)?.get("geometry") as? Map<*, *> + geometry?.let { toSimpleGeometry(it) } ?: emptyList() + } + return Distance(geojson, geometries) + } + + "Feature" -> { + val geometry = geojson["geometry"] as? Map<*, *> + if (geometry != null) return Distance(geojson, toSimpleGeometry(geometry)) + } + + else -> if (geojson.containsKey("type") && geojson.containsKey("coordinates")) { + return Distance(geojson, toSimpleGeometry(geojson)) + } + } + } + } + return context.error( + "'distance' expression requires valid geojson object that contains polygon geometry type." + ) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Format.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Format.kt new file mode 100644 index 00000000..c6f957fe --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Format.kt @@ -0,0 +1,143 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import androidx.compose.ui.graphics.Color +import ovh.plrapps.mapcompose.vector.spec.style.expression.ColorType +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.FormattedType +import ovh.plrapps.mapcompose.vector.spec.style.expression.NullType +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ResolvedImageType +import ovh.plrapps.mapcompose.vector.spec.style.expression.StringType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.array +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.Formatted +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.FormattedSection +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.ResolvedImage +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.VerticalAlign +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeOf +import ovh.plrapps.mapcompose.vector.spec.style.expression.valueToString + +/** Ported from `maplibre-style-spec/src/expression/definitions/format.ts`. */ +class Format(val sections: List
) : Expression { + + /** + * A section's content may be an `image` expression or anything coercible to a string; the + * options are only allowed on the token *after* a content token. + */ + class Section( + val content: Expression, + var scale: Expression? = null, + var font: Expression? = null, + var textColor: Expression? = null, + var verticalAlign: Expression? = null, + ) + + override val type: ExprType = FormattedType + + override fun evaluate(ctx: EvaluationContext): Any = Formatted( + sections.map { section -> + val evaluatedContent = section.content.evaluate(ctx) + if (typeOf(evaluatedContent) == ResolvedImageType) { + FormattedSection( + text = "", + image = evaluatedContent as ResolvedImage, + verticalAlign = VerticalAlign.fromStringOrNull( + section.verticalAlign?.evaluate(ctx) as? String + ), + ) + } else { + FormattedSection( + text = valueToString(evaluatedContent), + scale = (section.scale?.evaluate(ctx) as? Number)?.toDouble(), + fontStack = (section.font?.evaluate(ctx) as? List<*>)?.joinToString(","), + textColor = section.textColor?.evaluate(ctx) as? Color, + verticalAlign = VerticalAlign.fromStringOrNull( + section.verticalAlign?.evaluate(ctx) as? String + ), + ) + } + } + ) + + override fun eachChild(fn: (Expression) -> Unit) { + for (section in sections) { + fn(section.content) + section.scale?.let(fn) + section.font?.let(fn) + section.textColor?.let(fn) + section.verticalAlign?.let(fn) + } + } + + // Technically the combinatoric set of all children. + override fun outputDefined(): Boolean = false + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size < 2) return context.error("Expected at least one argument.") + + val firstArg = args[1] + if (firstArg is Map<*, *>) { + return context.error("First argument must be an image or text section.") + } + + val sections = mutableListOf
() + var nextTokenMayBeObject = false + for (i in 1 until args.size) { + val arg = args[i] + + if (nextTokenMayBeObject && arg is Map<*, *>) { + nextTokenMayBeObject = false + + var scale: Expression? = null + if (arg["font-scale"] != null) { + scale = context.parse(arg["font-scale"], 1, NumberType) ?: return null + } + + var font: Expression? = null + if (arg["text-font"] != null) { + font = context.parse(arg["text-font"], 1, array(StringType)) ?: return null + } + + var textColor: Expression? = null + if (arg["text-color"] != null) { + textColor = context.parse(arg["text-color"], 1, ColorType) ?: return null + } + + var verticalAlign: Expression? = null + val va = arg["vertical-align"] + if (va != null) { + if (va is String && VerticalAlign.fromStringOrNull(va) == null) { + return context.error( + "'vertical-align' must be one of: 'bottom', 'center', 'top' but " + + "found '$va' instead." + ) + } + verticalAlign = context.parse(va, 1, StringType) ?: return null + } + + val lastExpression = sections.last() + lastExpression.scale = scale + lastExpression.font = font + lastExpression.textColor = textColor + lastExpression.verticalAlign = verticalAlign + } else { + val content = context.parse(args[i], 1, ValueType) ?: return null + + val kind = content.type + if (kind != StringType && kind != ValueType && kind != NullType && kind != ResolvedImageType) { + return context.error("Formatted text type must be 'string', 'value', 'image' or 'null'.") + } + + nextTokenMayBeObject = true + sections.add(Section(content)) + } + } + + return Format(sections) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/GlobalState.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/GlobalState.kt new file mode 100644 index 00000000..8b7e581c --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/GlobalState.kt @@ -0,0 +1,48 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType + +/** Ported from `maplibre-style-spec/src/expression/definitions/global_state.ts`. */ +class GlobalState(val stateKey: String) : Expression { + + override val type: ExprType = ValueType + + override fun evaluate(ctx: EvaluationContext): Any? { + val globalState = ctx.globals?.globalState + if (globalState.isNullOrEmpty()) return null + return globalState[stateKey] + } + + override fun eachChild(fn: (Expression) -> Unit) = Unit + + override fun outputDefined(): Boolean = false + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size != 2) { + return context.error("Expected 1 argument, but found ${args.size - 1} instead.") + } + + val key = args[1] + ?: return context.error("Global state property must be defined.") + + if (key !is String) { + return context.error( + "Global state property must be string, but found ${ + when (key) { + is Boolean -> "boolean" + is Number -> "number" + else -> "object" + } + } instead." + ) + } + + return GlobalState(key) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Image.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Image.kt new file mode 100644 index 00000000..54fe74c8 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Image.kt @@ -0,0 +1,38 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ResolvedImageType +import ovh.plrapps.mapcompose.vector.spec.style.expression.StringType +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.ResolvedImage + +/** Ported from `maplibre-style-spec/src/expression/definitions/image.ts`. */ +class Image(val input: Expression) : Expression { + + override val type: ExprType = ResolvedImageType + + override fun evaluate(ctx: EvaluationContext): Any? { + val evaluatedImageName = input.evaluate(ctx) as? String + val value = ResolvedImage.fromString(evaluatedImageName) ?: return null + val available = ctx.availableImages?.contains(evaluatedImageName) ?: value.available + return value.copy(available = available) + } + + override fun eachChild(fn: (Expression) -> Unit) = fn(input) + + // The output depends on the list of available images in the evaluation context. + override fun outputDefined(): Boolean = false + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size != 2) return context.error("Expected two arguments.") + + val name = context.parse(args[1], 1, StringType) + ?: return context.error("No image name provided.") + + return Image(name) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/In.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/In.kt new file mode 100644 index 00000000..4bf11665 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/In.kt @@ -0,0 +1,87 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.BooleanType +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.NativeType +import ovh.plrapps.mapcompose.vector.spec.style.expression.NullType +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.RuntimeError +import ovh.plrapps.mapcompose.vector.spec.style.expression.StringType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.isValidNativeType +import ovh.plrapps.mapcompose.vector.spec.style.expression.isValidType +import ovh.plrapps.mapcompose.vector.spec.style.expression.jsIndexOf +import ovh.plrapps.mapcompose.vector.spec.style.expression.jsTruthy +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeOf +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeToString +import ovh.plrapps.mapcompose.vector.spec.style.expression.jsStringify + +/** Ported from `maplibre-style-spec/src/expression/definitions/in.ts`. */ +class In(val needle: Expression, val haystack: Expression, val key: String) : Expression { + + override val type: ExprType = BooleanType + + override fun evaluate(ctx: EvaluationContext): Any { + val needleValue = needle.evaluate(ctx) + val haystackValue = haystack.evaluate(ctx) + + if (!jsTruthy(haystackValue)) return false + + if (!isValidNativeType( + needleValue, + listOf(NativeType.BOOLEAN, NativeType.STRING, NativeType.NUMBER, NativeType.NULL), + ) + ) { + throw RuntimeError( + "Expected first argument to be of type boolean, string, number or null, " + + "but found ${typeToString(typeOf(needleValue))} instead.", + key, + ) + } + + if (!isValidNativeType(haystackValue, listOf(NativeType.STRING, NativeType.ARRAY))) { + throw RuntimeError( + "Expected second argument to be of type array or string, " + + "but found ${typeToString(typeOf(haystackValue))} instead.", + key, + ) + } + + return when (haystackValue) { + is String -> haystackValue.contains(jsStringify(needleValue)) + is List<*> -> jsIndexOf(haystackValue, needleValue) >= 0 + else -> false + } + } + + override fun eachChild(fn: (Expression) -> Unit) { + fn(needle) + fn(haystack) + } + + override fun outputDefined(): Boolean = true + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size != 3) { + return context.error("Expected 2 arguments, but found ${args.size - 1} instead.") + } + + val needle = context.parse(args[1], 1, ValueType) + val haystack = context.parse(args[2], 2, ValueType) + if (needle == null || haystack == null) return null + + if (!isValidType(needle.type, listOf(BooleanType, StringType, NumberType, NullType, ValueType))) { + return context.error( + "Expected first argument to be of type boolean, string, number or null, " + + "but found ${typeToString(needle.type)} instead" + ) + } + + return In(needle, haystack, context.key) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/IndexOf.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/IndexOf.kt new file mode 100644 index 00000000..cb11cf0e --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/IndexOf.kt @@ -0,0 +1,103 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.BooleanType +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.NativeType +import ovh.plrapps.mapcompose.vector.spec.style.expression.NullType +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.RuntimeError +import ovh.plrapps.mapcompose.vector.spec.style.expression.StringType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.isValidNativeType +import ovh.plrapps.mapcompose.vector.spec.style.expression.isValidType +import ovh.plrapps.mapcompose.vector.spec.style.expression.jsIndexOf +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeOf +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeToString +import ovh.plrapps.mapcompose.vector.spec.style.expression.jsStringify + +/** Ported from `maplibre-style-spec/src/expression/definitions/index_of.ts`. */ +class IndexOf( + val needle: Expression, + val haystack: Expression, + val key: String, + val fromIndex: Expression? = null, +) : Expression { + + override val type: ExprType = NumberType + + override fun evaluate(ctx: EvaluationContext): Any { + val needleValue = needle.evaluate(ctx) + val haystackValue = haystack.evaluate(ctx) + + if (!isValidNativeType( + needleValue, + listOf(NativeType.BOOLEAN, NativeType.STRING, NativeType.NUMBER, NativeType.NULL), + ) + ) { + throw RuntimeError( + "Expected first argument to be of type boolean, string, number or null, " + + "but found ${typeToString(typeOf(needleValue))} instead.", + key, + ) + } + + val from = (fromIndex?.evaluate(ctx) as? Number)?.toInt() ?: 0 + + return when (haystackValue) { + is String -> { + val rawIndex = haystackValue.indexOf(jsStringify(needleValue), startIndex = from.coerceAtLeast(0)) + if (rawIndex == -1) { + -1.0 + } else { + // The index may be affected by surrogate pairs, so count code points. + haystackValue.substring(0, rawIndex).codePointCountCompat().toDouble() + } + } + + is List<*> -> jsIndexOf(haystackValue, needleValue, from).toDouble() + + else -> throw RuntimeError( + "Expected second argument to be of type array or string, " + + "but found ${typeToString(typeOf(haystackValue))} instead.", + key, + ) + } + } + + override fun eachChild(fn: (Expression) -> Unit) { + fn(needle) + fn(haystack) + fromIndex?.let(fn) + } + + override fun outputDefined(): Boolean = false + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size <= 2 || args.size >= 5) { + return context.error("Expected 2 or 3 arguments, but found ${args.size - 1} instead.") + } + + val needle = context.parse(args[1], 1, ValueType) + val haystack = context.parse(args[2], 2, ValueType) + if (needle == null || haystack == null) return null + + if (!isValidType(needle.type, listOf(BooleanType, StringType, NumberType, NullType, ValueType))) { + return context.error( + "Expected first argument to be of type boolean, string, number or null, " + + "but found ${typeToString(needle.type)} instead" + ) + } + + return if (args.size == 4) { + val fromIndex = context.parse(args[3], 3, NumberType) ?: return null + IndexOf(needle, haystack, context.key, fromIndex) + } else { + IndexOf(needle, haystack, context.key) + } + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Interpolate.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Interpolate.kt new file mode 100644 index 00000000..6863cb7b --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Interpolate.kt @@ -0,0 +1,227 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import androidx.compose.ui.graphics.Color +import ovh.plrapps.mapcompose.vector.spec.style.expression.ArrayType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ColorType +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.Stops +import ovh.plrapps.mapcompose.vector.spec.style.expression.UnitBezier +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.colorspaces.InterpolationColorSpace +import ovh.plrapps.mapcompose.vector.spec.style.expression.colorspaces.interpolateColor +import ovh.plrapps.mapcompose.vector.spec.style.expression.colorspaces.interpolateNumber +import ovh.plrapps.mapcompose.vector.spec.style.expression.findStopLessThanOrEqualTo +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeToString +import kotlin.math.pow + +/** The interpolation curve of an `interpolate` expression. */ +sealed class InterpolationType { + data object Linear : InterpolationType() + data class Exponential(val base: Double) : InterpolationType() + data class CubicBezier(val controlPoints: List) : InterpolationType() +} + +/** Ported from `maplibre-style-spec/src/expression/definitions/interpolate.ts`. */ +class Interpolate( + override val type: ExprType, + val operator: String, + val interpolation: InterpolationType, + val input: Expression, + stops: Stops, + val key: String, +) : Expression { + + val labels: List = stops.map { it.first } + val outputs: List = stops.map { it.second } + + override fun evaluate(ctx: EvaluationContext): Any? { + if (labels.size == 1) return outputs[0].evaluate(ctx) + + val value = (input.evaluate(ctx) as Number).toDouble() + if (value <= labels[0]) return outputs[0].evaluate(ctx) + + val stopCount = labels.size + if (value >= labels[stopCount - 1]) return outputs[stopCount - 1].evaluate(ctx) + + val index = findStopLessThanOrEqualTo(labels, value, key) + val lower = labels[index] + val upper = labels[index + 1] + val t = interpolationFactor(interpolation, value, lower, upper) + + val outputLower = outputs[index].evaluate(ctx) + val outputUpper = outputs[index + 1].evaluate(ctx) + + val space = when (operator) { + "interpolate-hcl" -> InterpolationColorSpace.HCL + "interpolate-lab" -> InterpolationColorSpace.LAB + else -> InterpolationColorSpace.RGB + } + + return when { + type == NumberType -> interpolateNumber( + (outputLower as Number).toDouble(), + (outputUpper as Number).toDouble(), + t, + ) + + type == ColorType -> interpolateColor(outputLower as Color, outputUpper as Color, t, space) + + type is ArrayType -> { + val a = outputLower as List<*> + val b = outputUpper as List<*> + a.mapIndexed { i, v -> + interpolateNumber((v as Number).toDouble(), (b[i] as Number).toDouble(), t) + } + } + + else -> null + } + } + + override fun eachChild(fn: (Expression) -> Unit) { + fn(input) + outputs.forEach(fn) + } + + override fun outputDefined(): Boolean = outputs.all { it.outputDefined() } + + companion object { + fun interpolationFactor( + interpolation: InterpolationType, + input: Double, + lower: Double, + upper: Double, + ): Double = when (interpolation) { + is InterpolationType.Exponential -> exponentialInterpolation(input, interpolation.base, lower, upper) + is InterpolationType.Linear -> exponentialInterpolation(input, 1.0, lower, upper) + is InterpolationType.CubicBezier -> { + val c = interpolation.controlPoints + UnitBezier(c[0], c[1], c[2], c[3]).solve(exponentialInterpolation(input, 1.0, lower, upper)) + } + } + + /** + * Returns the ratio used to interpolate between two exponential stops. + * + * Two consecutive stops define a scaled and shifted exponential `f(x) = a * base^x + b`; + * the algebra in the upstream comment reduces the ratio to + * `(base^(x-x0) - 1) / (base^(x1-x0) - 1)`. + */ + private fun exponentialInterpolation( + input: Double, + base: Double, + lowerValue: Double, + upperValue: Double, + ): Double { + val difference = upperValue - lowerValue + val progress = input - lowerValue + + return when { + difference == 0.0 -> 0.0 + base == 1.0 -> progress / difference + else -> (base.pow(progress) - 1) / (base.pow(difference) - 1) + } + } + + private val INTERPOLATABLE_TYPES = listOf(NumberType, ColorType) + + fun parser(operator: String): (List, ParsingContext) -> Expression? = { args, context -> + parse(operator, args, context) + } + + private fun parse(operator: String, args: List, context: ParsingContext): Expression? { + val interpolationArg = args.getOrNull(1) + + if (interpolationArg !is List<*> || interpolationArg.isEmpty()) { + return context.error("Expected an interpolation type expression.", 1) + } + + val interpolation: InterpolationType = when (interpolationArg[0]) { + "linear" -> InterpolationType.Linear + "exponential" -> { + val base = interpolationArg.getOrNull(1) as? Number + ?: return context.error("Exponential interpolation requires a numeric base.", 1, 1) + InterpolationType.Exponential(base.toDouble()) + } + + "cubic-bezier" -> { + val controlPoints = interpolationArg.drop(1) + if (controlPoints.size != 4 || + controlPoints.any { it !is Number || it.toDouble() < 0 || it.toDouble() > 1 } + ) { + return context.error( + "Cubic bezier interpolation requires four numeric arguments with values " + + "between 0 and 1.", + 1, + ) + } + InterpolationType.CubicBezier(controlPoints.map { (it as Number).toDouble() }) + } + + else -> return context.error("Unknown interpolation type ${interpolationArg[0]}", 1, 0) + } + + if (args.size - 1 < 4) { + return context.error("Expected at least 4 arguments, but found only ${args.size - 1}.") + } + if ((args.size - 1) % 2 != 0) { + return context.error("Expected an even number of arguments.") + } + + val input = context.parse(args[2], 2, NumberType) ?: return null + + val stops = mutableListOf>() + + var outputType: ExprType? = null + if (operator == "interpolate-hcl" || operator == "interpolate-lab") { + outputType = ColorType + } else if (context.expectedType != null && context.expectedType != ValueType) { + outputType = context.expectedType + } + + val rest = args.drop(3) + var i = 0 + while (i < rest.size) { + val label = (rest[i] as? Number)?.toDouble() + val value = rest[i + 1] + + val labelKey = i + 3 + val valueKey = i + 4 + + if (label == null) { + return context.error( + "Input/output pairs for \"interpolate\" expressions must be defined using " + + "literal numeric values (not computed expressions) for the input values.", + labelKey, + ) + } + + if (stops.isNotEmpty() && stops.last().first >= label) { + return context.error( + "Input/output pairs for \"interpolate\" expressions must be arranged with " + + "input values in strictly ascending order.", + labelKey, + ) + } + + val parsed = context.parse(value, valueKey, outputType) ?: return null + if (outputType == null) outputType = parsed.type + stops.add(label to parsed) + i += 2 + } + + val resolved = outputType!! + val interpolatable = resolved in INTERPOLATABLE_TYPES || + (resolved is ArrayType && resolved.itemType == NumberType && resolved.n != null) + if (!interpolatable) { + return context.error("Type ${typeToString(resolved)} is not interpolatable.") + } + + return Interpolate(resolved, operator, interpolation, input, stops, context.key) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Length.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Length.kt new file mode 100644 index 00000000..58c9f41e --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Length.kt @@ -0,0 +1,65 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.ArrayType +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.RuntimeError +import ovh.plrapps.mapcompose.vector.spec.style.expression.StringType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeOf +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeToString + +/** Ported from `maplibre-style-spec/src/expression/definitions/length.ts`. */ +class Length(val input: Expression, val key: String) : Expression { + + override val type: ExprType = NumberType + + override fun evaluate(ctx: EvaluationContext): Any { + return when (val value = input.evaluate(ctx)) { + // JavaScript's [...string].length counts code points, not UTF-16 units. + is String -> value.codePointCountCompat().toDouble() + is List<*> -> value.size.toDouble() + else -> throw RuntimeError( + "Expected value to be of type string or array, but found ${typeToString(typeOf(value))} instead.", + key, + ) + } + } + + override fun eachChild(fn: (Expression) -> Unit) = fn(input) + + override fun outputDefined(): Boolean = false + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size != 2) { + return context.error("Expected 1 argument, but found ${args.size - 1} instead.") + } + + val input = context.parse(args[1], 1) ?: return null + + if (input.type !is ArrayType && input.type != StringType && input.type != ValueType) { + return context.error( + "Expected argument of type string or array, but found ${typeToString(input.type)} instead." + ) + } + + return Length(input, context.key) + } + } +} + +/** Counts Unicode code points, matching JavaScript's `[...str].length`. */ +internal fun String.codePointCountCompat(): Int { + var count = 0 + var i = 0 + while (i < length) { + val c = this[i] + i += if (c.isHighSurrogate() && i + 1 < length && this[i + 1].isLowSurrogate()) 2 else 1 + count++ + } + return count +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Let.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Let.kt new file mode 100644 index 00000000..4d2e269c --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Let.kt @@ -0,0 +1,66 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext + +/** Ported from `maplibre-style-spec/src/expression/definitions/let.ts`. */ +class Let(val bindings: List>, val result: Expression) : Expression { + + override val type: ExprType = result.type + + override fun evaluate(ctx: EvaluationContext): Any? = result.evaluate(ctx) + + override fun eachChild(fn: (Expression) -> Unit) { + for (binding in bindings) fn(binding.second) + fn(result) + } + + override fun outputDefined(): Boolean = result.outputDefined() + + companion object { + private val INVALID_NAME = Regex("[^a-zA-Z0-9_]") + + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size < 4) { + return context.error("Expected at least 3 arguments, but found ${args.size - 1} instead.") + } + + val bindings = mutableListOf>() + var i = 1 + while (i < args.size - 1) { + val name = args[i] + if (name !is String) { + return context.error("Expected string, but found ${jsTypeName(name)} instead.", i) + } + if (INVALID_NAME.containsMatchIn(name)) { + return context.error( + "Variable names must contain only alphanumeric characters or '_'.", + i, + ) + } + val value = context.parse(args[i + 1], i + 1) ?: return null + bindings.add(name to value) + i += 2 + } + + val result = context.parse( + args[args.size - 1], + args.size - 1, + context.expectedType, + bindings, + ) ?: return null + + return Let(bindings, result) + } + + private fun jsTypeName(v: Any?): String = when (v) { + null -> "object" + is String -> "string" + is Boolean -> "boolean" + is Number -> "number" + else -> "object" + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Literal.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Literal.kt new file mode 100644 index 00000000..7176331d --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Literal.kt @@ -0,0 +1,57 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.ArrayType +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.isValue +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeOf +import ovh.plrapps.mapcompose.vector.spec.style.expression.valueToJsonString + +/** Ported from `maplibre-style-spec/src/expression/definitions/literal.ts`. */ +class Literal(override val type: ExprType, val value: Any?) : Expression { + + override fun evaluate(ctx: EvaluationContext): Any? = value + + override fun eachChild(fn: (Expression) -> Unit) = Unit + + override fun outputDefined(): Boolean = true + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size != 2) { + return context.error( + "'literal' expression requires exactly one argument, but found ${args.size - 1} instead." + ) + } + + if (!isValue(args[1])) { + return context.error("invalid value of type \"${jsTypeName(args[1])}\"") + } + + val value = args[1] + var type = typeOf(value) + + // special case: infer the item type if possible for zero-length arrays + val expected = context.expectedType + if (type is ArrayType && type.n == 0 && + expected is ArrayType && (expected.n == null || expected.n == 0) + ) { + type = expected + } + + return Literal(type, value) + } + + private fun jsTypeName(v: Any?): String = when (v) { + null -> "object" + is String -> "string" + is Boolean -> "boolean" + is Number -> "number" + else -> "object" + } + } + + override fun toString(): String = "Literal(${valueToJsonString(value)})" +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Match.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Match.kt new file mode 100644 index 00000000..bb82c42c --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Match.kt @@ -0,0 +1,123 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.formatNumber +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeOf +import kotlin.math.abs +import kotlin.math.floor + +/** + * Ported from `maplibre-style-spec/src/expression/definitions/match.ts`. + * + * Branch labels are keyed by their JavaScript string form (`String(label)`), which is why numeric + * labels must be integers: `1` and `1.0` would otherwise collide inconsistently. + */ +class Match( + val inputType: ExprType, + override val type: ExprType, + val input: Expression, + val cases: Map, + val outputs: List, + val otherwise: Expression, +) : Expression { + + override fun evaluate(ctx: EvaluationContext): Any? { + val inputValue = input.evaluate(ctx) + val output = if (typeOf(inputValue) == inputType) { + cases[labelKey(inputValue)]?.let { outputs[it] } ?: otherwise + } else { + otherwise + } + return output.evaluate(ctx) + } + + override fun eachChild(fn: (Expression) -> Unit) { + fn(input) + outputs.forEach(fn) + fn(otherwise) + } + + override fun outputDefined(): Boolean = outputs.all { it.outputDefined() } && otherwise.outputDefined() + + companion object { + private const val MAX_SAFE_INTEGER = 9007199254740991.0 + + /** `String(label)` — the key the upstream `cases` object is indexed by. */ + private fun labelKey(label: Any?): String = when (label) { + is Number -> formatNumber(label.toDouble()) + else -> label.toString() + } + + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size < 5) { + return context.error("Expected at least 4 arguments, but found only ${args.size - 1}.") + } + if (args.size % 2 != 1) { + return context.error("Expected an even number of arguments.") + } + + var inputType: ExprType? = null + var outputType: ExprType? = null + if (context.expectedType != null && context.expectedType != ValueType) { + outputType = context.expectedType + } + val cases = mutableMapOf() + val outputs = mutableListOf() + + var i = 2 + while (i < args.size - 1) { + val rawLabels = args[i] + val value = args[i + 1] + + val labels: List = if (rawLabels is List<*>) rawLabels else listOf(rawLabels) + + val labelContext = context.concat(i) + if (labels.isEmpty()) { + return labelContext.error("Expected at least one branch label.") + } + + for (label in labels) { + if (label !is Number && label !is String) { + return labelContext.error("Branch labels must be numbers or strings.") + } else if (label is Number && abs(label.toDouble()) > MAX_SAFE_INTEGER) { + return labelContext.error( + "Branch labels must be integers no larger than ${formatNumber(MAX_SAFE_INTEGER)}." + ) + } else if (label is Number && floor(label.toDouble()) != label.toDouble()) { + return labelContext.error("Numeric branch labels must be integer values.") + } else if (inputType == null) { + inputType = typeOf(label) + } else if (labelContext.checkSubtype(inputType, typeOf(label)) != null) { + return null + } + + val key = labelKey(label) + if (cases.containsKey(key)) { + return labelContext.error("Branch labels must be unique.") + } + cases[key] = outputs.size + } + + val result = context.parse(value, i, outputType) ?: return null + if (outputType == null) outputType = result.type + outputs.add(result) + i += 2 + } + + val input = context.parse(args[1], 1, ValueType) ?: return null + val otherwise = context.parse(args[args.size - 1], args.size - 1, outputType) ?: return null + + if (input.type != ValueType && + context.concat(1).checkSubtype(inputType!!, input.type) != null + ) { + return null + } + + return Match(inputType!!, outputType!!, input, cases, outputs, otherwise) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/NumberFormat.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/NumberFormat.kt new file mode 100644 index 00000000..01be9b58 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/NumberFormat.kt @@ -0,0 +1,146 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.StringType +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.pow +import kotlin.math.roundToLong + +/** + * Ported from `maplibre-style-spec/src/expression/definitions/number_format.ts`. + * + * **Divergence from MapLibre.** Upstream delegates to `Intl.NumberFormat`, which Kotlin + * Multiplatform has no equivalent of. This implementation is locale-agnostic and reproduces only + * the parts that are locale-independent: + * - `min-fraction-digits` / `max-fraction-digits` (defaults 0 and 3, as in ECMA-402) + * - grouping separators are **not** inserted + * - `currency` and `unit` are appended as a suffix rather than formatted per CLDR + * + * `locale` is parsed and type-checked so styles using it still load, but does not affect output. + */ +class NumberFormat( + val number: Expression, + val locale: Expression?, + val currency: Expression?, + val unit: Expression?, + val minFractionDigits: Expression?, + val maxFractionDigits: Expression?, +) : Expression { + + override val type: ExprType = StringType + + override fun evaluate(ctx: EvaluationContext): Any { + val value = (number.evaluate(ctx) as Number).toDouble() + val minDigits = (minFractionDigits?.evaluate(ctx) as? Number)?.toInt() ?: 0 + val maxDigits = (maxFractionDigits?.evaluate(ctx) as? Number)?.toInt() ?: maxOf(minDigits, 3) + + val formatted = formatDecimal(value, minDigits, maxDigits) + + val currencyCode = currency?.evaluate(ctx) as? String + val unitName = unit?.evaluate(ctx) as? String + return when { + currencyCode != null -> "$currencyCode$formatted" + unitName != null -> "$formatted $unitName" + else -> formatted + } + } + + override fun eachChild(fn: (Expression) -> Unit) { + fn(number) + locale?.let(fn) + currency?.let(fn) + unit?.let(fn) + minFractionDigits?.let(fn) + maxFractionDigits?.let(fn) + } + + override fun outputDefined(): Boolean = false + + companion object { + /** Beyond this, a Double carries no meaningful precision and a Long would overflow. */ + private const val MAX_FRACTION_DIGITS = 15 + + private fun formatDecimal(value: Double, minDigits: Int, maxDigits: Int): String { + if (value.isNaN()) return "NaN" + if (value.isInfinite()) return if (value > 0) "∞" else "-∞" + + val negative = value < 0 + val magnitude = abs(value) + + // Split rather than scaling the whole value: a maxDigits of 20 would overflow a Long. + val wholePart = floor(magnitude) + var intPart = wholePart.toLong().toString() + + val effectiveMax = maxDigits.coerceAtMost(MAX_FRACTION_DIGITS) + var fracPart = if (effectiveMax > 0) { + val scale = 10.0.pow(effectiveMax) + var scaled = ((magnitude - wholePart) * scale).roundToLong() + if (scaled >= scale.toLong()) { + // Rounding carried into the integer part. + intPart = (wholePart.toLong() + 1).toString() + scaled = 0 + } + scaled.toString().padStart(effectiveMax, '0') + } else { + "" + } + + // Pad out to minDigits; doubles carry no more precision than effectiveMax anyway. + if (fracPart.length < minDigits) fracPart = fracPart.padEnd(minDigits, '0') + while (fracPart.length > minDigits && fracPart.endsWith('0')) { + fracPart = fracPart.dropLast(1) + } + + if (intPart.isEmpty()) intPart = "0" + val body = if (fracPart.isEmpty()) intPart else "$intPart.$fracPart" + return if (negative && body.any { it in '1'..'9' }) "-$body" else body + } + + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size != 3) return context.error("Expected two arguments.") + + val number = context.parse(args[1], 1, NumberType) ?: return null + + val options = args[2] + if (options !is Map<*, *>) { + return context.error("NumberFormat options argument must be an object.") + } + + var locale: Expression? = null + if (options["locale"] != null) { + locale = context.parse(options["locale"], 1, StringType) ?: return null + } + + var currency: Expression? = null + if (options["currency"] != null) { + currency = context.parse(options["currency"], 1, StringType) ?: return null + } + + var unit: Expression? = null + if (options["unit"] != null) { + unit = context.parse(options["unit"], 1, StringType) ?: return null + } + + if (currency != null && unit != null) { + return context.error("NumberFormat options `currency` and `unit` are mutually exclusive") + } + + var minFractionDigits: Expression? = null + if (options["min-fraction-digits"] != null) { + minFractionDigits = context.parse(options["min-fraction-digits"], 1, NumberType) ?: return null + } + + var maxFractionDigits: Expression? = null + if (options["max-fraction-digits"] != null) { + maxFractionDigits = context.parse(options["max-fraction-digits"], 1, NumberType) ?: return null + } + + return NumberFormat(number, locale, currency, unit, minFractionDigits, maxFractionDigits) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Registry.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Registry.kt new file mode 100644 index 00000000..95e773b5 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Registry.kt @@ -0,0 +1,58 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.CompoundExpression +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExpressionRegistry + +/** + * Operator name to parser. + * + * Ported from `maplibre-style-spec/src/expression/definitions/index.ts`. Every operator not listed + * here falls through to [CompoundExpression.parse], which looks it up in the table-driven + * definitions and reports `Unknown expression "…"` if it isn't there either. + */ +val expressions: ExpressionRegistry = buildMap { + // Special forms with their own parse logic. + put("==", Comparison.parser("==")) + put("!=", Comparison.parser("!=")) + put(">", Comparison.parser(">")) + put("<", Comparison.parser("<")) + put(">=", Comparison.parser(">=")) + put("<=", Comparison.parser("<=")) + put("array", Assertion::parse) + put("at", At::parse) + put("boolean", Assertion::parse) + put("case", Case::parse) + put("coalesce", Coalesce::parse) + put("collator", Collator::parse) + put("distance", Distance::parse) + put("format", Format::parse) + put("global-state", GlobalState::parse) + put("image", Image::parse) + put("in", In::parse) + put("index-of", IndexOf::parse) + put("interpolate", Interpolate.parser("interpolate")) + put("interpolate-hcl", Interpolate.parser("interpolate-hcl")) + put("interpolate-lab", Interpolate.parser("interpolate-lab")) + put("length", Length::parse) + put("let", Let::parse) + put("literal", Literal::parse) + put("match", Match::parse) + put("number", Assertion::parse) + put("number-format", NumberFormat::parse) + put("object", Assertion::parse) + put("semiliteral", Semiliteral::parse) + put("slice", Slice::parse) + put("step", Step::parse) + put("string", Assertion::parse) + put("to-boolean", Coercion::parse) + put("to-color", Coercion::parse) + put("to-number", Coercion::parse) + put("to-string", Coercion::parse) + put("var", Var::parse) + put("within", Within::parse) + + // Everything else is table-driven. + for (name in CompoundExpression.definitions.keys) { + put(name, CompoundExpression::parse) + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Semiliteral.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Semiliteral.kt new file mode 100644 index 00000000..863eaa31 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Semiliteral.kt @@ -0,0 +1,74 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.ArrayType +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.array +import ovh.plrapps.mapcompose.vector.spec.style.expression.isValue +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeOf + +/** + * Like `literal`, but the array elements may themselves be expressions. + * + * Ported from `maplibre-style-spec/src/expression/definitions/semiliteral.ts`. + */ +class Semiliteral(val arr: List) : Expression { + + override val type: ExprType = run { + var elementType: ExprType? = null + for (expr in arr) { + if (elementType == null) { + elementType = expr.type + } else if (elementType == expr.type) { + continue + } else { + elementType = ValueType + break + } + } + array(elementType ?: ValueType, arr.size) + } + + override fun evaluate(ctx: EvaluationContext): Any = arr.map { it.evaluate(ctx) } + + override fun eachChild(fn: (Expression) -> Unit) = arr.forEach(fn) + + override fun outputDefined(): Boolean = arr.all { it.outputDefined() } + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size != 2) { + return context.error( + "'semiliteral' expression requires exactly one argument, but found ${args.size - 1} instead." + ) + } + + if (!isValue(args[1])) { + return context.error("invalid value of type \"${jsTypeNameOf(args[1])}\"") + } + + val value = args[1] + val type = typeOf(value) + + return if (type is ArrayType) { + val parsed = (value as List<*>).map { item -> + context.parse(item, null, ValueType) ?: return null + } + Semiliteral(parsed) + } else { + Literal(type, value) + } + } + + private fun jsTypeNameOf(v: Any?): String = when (v) { + null -> "object" + is String -> "string" + is Boolean -> "boolean" + is Number -> "number" + else -> "object" + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Slice.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Slice.kt new file mode 100644 index 00000000..39b8faf3 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Slice.kt @@ -0,0 +1,82 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.RuntimeError +import ovh.plrapps.mapcompose.vector.spec.style.expression.StringType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.array +import ovh.plrapps.mapcompose.vector.spec.style.expression.isValidType +import ovh.plrapps.mapcompose.vector.spec.style.expression.jsSliceRange +import ovh.plrapps.mapcompose.vector.spec.style.expression.toCodePointStrings +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeOf +import ovh.plrapps.mapcompose.vector.spec.style.expression.typeToString + +/** Ported from `maplibre-style-spec/src/expression/definitions/slice.ts`. */ +class Slice( + override val type: ExprType, + val input: Expression, + val beginIndex: Expression, + val key: String, + val endIndex: Expression? = null, +) : Expression { + + override fun evaluate(ctx: EvaluationContext): Any { + val inputValue = input.evaluate(ctx) + val begin = (beginIndex.evaluate(ctx) as Number).toInt() + val end = (endIndex?.evaluate(ctx) as? Number)?.toInt() + + return when (inputValue) { + is String -> { + // Indices may be affected by surrogate pairs. + val cps = inputValue.toCodePointStrings() + cps.slice(jsSliceRange(cps.size, begin, end)).joinToString(separator = "") + } + + is List<*> -> inputValue.slice(jsSliceRange(inputValue.size, begin, end)) + + else -> throw RuntimeError( + "Expected first argument to be of type array or string, " + + "but found ${typeToString(typeOf(inputValue))} instead.", + key, + ) + } + } + + override fun eachChild(fn: (Expression) -> Unit) { + fn(input) + fn(beginIndex) + endIndex?.let(fn) + } + + override fun outputDefined(): Boolean = false + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size <= 2 || args.size >= 5) { + return context.error("Expected 2 or 3 arguments, but found ${args.size - 1} instead.") + } + + val input = context.parse(args[1], 1, ValueType) + val beginIndex = context.parse(args[2], 2, NumberType) + if (input == null || beginIndex == null) return null + + if (!isValidType(input.type, listOf(array(ValueType), StringType, ValueType))) { + return context.error( + "Expected first argument to be of type array or string, " + + "but found ${typeToString(input.type)} instead" + ) + } + + return if (args.size == 4) { + val endIndex = context.parse(args[3], 3, NumberType) ?: return null + Slice(input.type, input, beginIndex, context.key, endIndex) + } else { + Slice(input.type, input, beginIndex, context.key) + } + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Step.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Step.kt new file mode 100644 index 00000000..92f728ff --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Step.kt @@ -0,0 +1,94 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.Stops +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.findStopLessThanOrEqualTo + +/** Ported from `maplibre-style-spec/src/expression/definitions/step.ts`. */ +class Step( + override val type: ExprType, + val input: Expression, + stops: Stops, + val key: String, +) : Expression { + + val labels: List = stops.map { it.first } + val outputs: List = stops.map { it.second } + + override fun evaluate(ctx: EvaluationContext): Any? { + if (labels.size == 1) return outputs[0].evaluate(ctx) + + val value = (input.evaluate(ctx) as Number).toDouble() + if (value <= labels[0]) return outputs[0].evaluate(ctx) + + val stopCount = labels.size + if (value >= labels[stopCount - 1]) return outputs[stopCount - 1].evaluate(ctx) + + val index = findStopLessThanOrEqualTo(labels, value, key) + return outputs[index].evaluate(ctx) + } + + override fun eachChild(fn: (Expression) -> Unit) { + fn(input) + outputs.forEach(fn) + } + + override fun outputDefined(): Boolean = outputs.all { it.outputDefined() } + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size - 1 < 4) { + return context.error("Expected at least 4 arguments, but found only ${args.size - 1}.") + } + if ((args.size - 1) % 2 != 0) { + return context.error("Expected an even number of arguments.") + } + + val input = context.parse(args[1], 1, NumberType) ?: return null + + val stops = mutableListOf>() + + var outputType: ExprType? = null + if (context.expectedType != null && context.expectedType != ValueType) { + outputType = context.expectedType + } + + var i = 1 + while (i < args.size) { + val label = if (i == 1) Double.NEGATIVE_INFINITY else (args[i] as? Number)?.toDouble() + val value = args[i + 1] + + val labelKey = i + val valueKey = i + 1 + + if (label == null) { + return context.error( + "Input/output pairs for \"step\" expressions must be defined using literal " + + "numeric values (not computed expressions) for the input values.", + labelKey, + ) + } + + if (stops.isNotEmpty() && stops.last().first >= label) { + return context.error( + "Input/output pairs for \"step\" expressions must be arranged with input " + + "values in strictly ascending order.", + labelKey, + ) + } + + val parsed = context.parse(value, valueKey, outputType) ?: return null + if (outputType == null) outputType = parsed.type + stops.add(label to parsed) + i += 2 + } + + return Step(outputType!!, input, stops, context.key) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Var.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Var.kt new file mode 100644 index 00000000..1d5d15c2 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Var.kt @@ -0,0 +1,42 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext + +/** + * Ported from `maplibre-style-spec/src/expression/definitions/var.ts`. + * + * Resolution happens at *parse* time: the variable name is looked up in the enclosing [Scope] and + * the bound expression is referenced directly, so evaluation costs nothing extra. + */ +class Var(val name: String, val boundExpression: Expression) : Expression { + + override val type: ExprType = boundExpression.type + + override fun evaluate(ctx: EvaluationContext): Any? = boundExpression.evaluate(ctx) + + override fun eachChild(fn: (Expression) -> Unit) = Unit + + override fun outputDefined(): Boolean = false + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size != 2 || args[1] !is String) { + return context.error("'var' expression requires exactly one string literal argument.") + } + + val name = args[1] as String + if (!context.scope.has(name)) { + return context.error( + "Unknown variable \"$name\". Make sure \"$name\" has been bound in an enclosing " + + "\"let\" expression before using it.", + 1, + ) + } + + return Var(name, context.scope.get(name)) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Within.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Within.kt new file mode 100644 index 00000000..ce65688e --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/definitions/Within.kt @@ -0,0 +1,213 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.definitions + +import ovh.plrapps.mapcompose.vector.spec.style.expression.BooleanType +import ovh.plrapps.mapcompose.vector.spec.style.expression.CanonicalTileId +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvaluationContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.Expression +import ovh.plrapps.mapcompose.vector.spec.style.expression.ParsingContext +import ovh.plrapps.mapcompose.vector.spec.style.expression.Point2D +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.BBox +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.Coord +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.EXTENT +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.boxWithinBox +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.getTileCoordinates +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.lineStringWithinPolygons +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.newBBox +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.pointWithinPolygons +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.updateBBox +import ovh.plrapps.mapcompose.vector.spec.style.expression.isValue + +/** + * Ported from `maplibre-style-spec/src/expression/definitions/within.ts`. + * + * The GeoJSON argument is decoded once at parse time into `[lng, lat]` rings. At evaluation the + * rings are projected into *global* tile coordinates (`tileX * EXTENT + localX`) so they can be + * compared directly against MVT feature geometry. `Polygon` inputs are normalized to a + * single-element multipolygon, which gives identical results to upstream's separate code path. + */ +class Within(val geojson: Any?, private val polygons: List>>>) : Expression { + + override val type: ExprType = BooleanType + + override fun evaluate(ctx: EvaluationContext): Any { + val canonical = ctx.canonicalID() ?: return false + val geometry = ctx.geometry() + if (geometry.isEmpty()) return false + + return when (ctx.geometryType()) { + "Point" -> pointsWithinPolygons(geometry, canonical) + "LineString" -> linesWithinPolygons(geometry, canonical) + else -> false + } + } + + private fun pointsWithinPolygons(geometry: List>, canonical: CanonicalTileId): Boolean { + val pointBBox = newBBox() + val polyBBox = newBBox() + + val tilePolygons = getTilePolygons(polyBBox, canonical) + val tilePoints = getTilePoints(geometry, pointBBox, polyBBox, canonical) + if (!boxWithinBox(pointBBox, polyBBox)) return false + + return tilePoints.all { pointWithinPolygons(it, tilePolygons) } + } + + private fun linesWithinPolygons(geometry: List>, canonical: CanonicalTileId): Boolean { + val lineBBox = newBBox() + val polyBBox = newBBox() + + val tilePolygons = getTilePolygons(polyBBox, canonical) + val tileLines = getTileLines(geometry, lineBBox, polyBBox, canonical) + if (!boxWithinBox(lineBBox, polyBBox)) return false + + return tileLines.all { lineStringWithinPolygons(it, tilePolygons) } + } + + private fun getTilePolygons(bbox: BBox, canonical: CanonicalTileId): List>> = + polygons.map { polygon -> + polygon.map { ring -> + ring.map { position -> + val coord = getTileCoordinates(position, canonical) + updateBBox(bbox, coord) + coord + } + } + } + + private fun getTilePoints( + geometry: List>, + pointBBox: BBox, + polyBBox: BBox, + canonical: CanonicalTileId, + ): List { + val worldSize = (1 shl canonical.z).toDouble() * EXTENT + val shiftX = canonical.x.toDouble() * EXTENT + val shiftY = canonical.y.toDouble() * EXTENT + val tilePoints = mutableListOf() + for (points in geometry) { + for (point in points) { + val p = doubleArrayOf(point.x + shiftX, point.y + shiftY) + updatePoint(p, pointBBox, polyBBox, worldSize) + tilePoints.add(p) + } + } + return tilePoints + } + + private fun getTileLines( + geometry: List>, + lineBBox: BBox, + polyBBox: BBox, + canonical: CanonicalTileId, + ): List> { + val worldSize = (1 shl canonical.z).toDouble() * EXTENT + val shiftX = canonical.x.toDouble() * EXTENT + val shiftY = canonical.y.toDouble() * EXTENT + val tileLines = mutableListOf>() + for (line in geometry) { + val tileLine = mutableListOf() + for (point in line) { + val p = doubleArrayOf(point.x + shiftX, point.y + shiftY) + updateBBox(lineBBox, p) + tileLine.add(p) + } + tileLines.add(tileLine) + } + if (lineBBox[2] - lineBBox[0] <= worldSize / 2) { + resetBBox(lineBBox) + for (line in tileLines) { + for (p in line) updatePoint(p, lineBBox, polyBBox, worldSize) + } + } + return tileLines + } + + /** Shifts a point across the antimeridian when that brings it closer to the polygon. */ + private fun updatePoint(p: Coord, bbox: BBox, polyBBox: BBox, worldSize: Double) { + if (p[0] < polyBBox[0] || p[0] > polyBBox[2]) { + val halfWorldSize = worldSize * 0.5 + var shift = when { + p[0] - polyBBox[0] > halfWorldSize -> -worldSize + polyBBox[0] - p[0] > halfWorldSize -> worldSize + else -> 0.0 + } + if (shift == 0.0) { + shift = when { + p[0] - polyBBox[2] > halfWorldSize -> -worldSize + polyBBox[2] - p[0] > halfWorldSize -> worldSize + else -> 0.0 + } + } + p[0] += shift + } + updateBBox(bbox, p) + } + + private fun resetBBox(bbox: BBox) { + bbox[0] = Double.POSITIVE_INFINITY + bbox[1] = Double.POSITIVE_INFINITY + bbox[2] = Double.NEGATIVE_INFINITY + bbox[3] = Double.NEGATIVE_INFINITY + } + + override fun eachChild(fn: (Expression) -> Unit) = Unit + + override fun outputDefined(): Boolean = true + + companion object { + fun parse(args: List, context: ParsingContext): Expression? { + if (args.size != 2) { + return context.error( + "'within' expression requires exactly one argument, but found ${args.size - 1} instead." + ) + } + if (isValue(args[1])) { + val geojson = args[1] as? Map<*, *> + val polygons = geojson?.let { extractPolygons(it) } + if (polygons != null && polygons.isNotEmpty()) { + return Within(geojson, polygons) + } + } + return context.error( + "'within' expression requires valid geojson object that contains polygon geometry type." + ) + } + + /** Normalizes Polygon / MultiPolygon / Feature / FeatureCollection to a list of polygons. */ + private fun extractPolygons(geojson: Map<*, *>): List>>>? { + return when (geojson["type"]) { + "FeatureCollection" -> { + val out = mutableListOf>>>() + val features = geojson["features"] as? List<*> ?: return null + for (feature in features) { + val geometry = (feature as? Map<*, *>)?.get("geometry") as? Map<*, *> ?: continue + out += extractPolygons(geometry) ?: continue + } + out.ifEmpty { null } + } + + "Feature" -> (geojson["geometry"] as? Map<*, *>)?.let { extractPolygons(it) } + + "Polygon" -> coordsToPolygon(geojson["coordinates"])?.let { listOf(it) } + + "MultiPolygon" -> { + val coords = geojson["coordinates"] as? List<*> ?: return null + coords.mapNotNull { coordsToPolygon(it) }.ifEmpty { null } + } + + else -> null + } + } + + private fun coordsToPolygon(coords: Any?): List>>? { + val rings = coords as? List<*> ?: return null + return rings.map { ring -> + (ring as? List<*> ?: return null).map { position -> + val p = position as? List<*> ?: return null + p.map { (it as? Number)?.toDouble() ?: return null } + } + } + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/geometry/CheapRuler.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/geometry/CheapRuler.kt new file mode 100644 index 00000000..9bd211a0 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/geometry/CheapRuler.kt @@ -0,0 +1,94 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.geometry + +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sqrt + +/** + * Fast approximate geodesic distances, valid near a given latitude. + * + * Ported from `maplibre-style-spec/src/util/cheap_ruler.ts`, itself a subset of + * https://github.com/mapbox/cheap-ruler. Distances are in metres. + */ +class CheapRuler(lat: Double) { + + private val kx: Double + private val ky: Double + + init { + // Curvature formulas from https://en.wikipedia.org/wiki/Earth_radius#Meridional + val m = RAD * RE * 1000 + val coslat = cos(lat * RAD) + val w2 = 1 / (1 - E2 * (1 - coslat * coslat)) + val w = sqrt(w2) + + kx = m * w * coslat // based on normal radius of curvature + ky = m * w * w2 * (1 - E2) // based on meridional radius of curvature + } + + /** Distance in metres between two `[longitude, latitude]` points. */ + fun distance(a: Coord, b: Coord): Double { + val dx = wrap(a[0] - b[0]) * kx + val dy = (a[1] - b[1]) * ky + return sqrt(dx * dx + dy * dy) + } + + /** The closest point on [line] to [p], plus its segment index and position along that segment. */ + fun pointOnLine(line: List, p: Coord): PointOnLine { + var minDist = Double.POSITIVE_INFINITY + var minX = 0.0 + var minY = 0.0 + var minI = 0 + var minT = 0.0 + + for (i in 0 until line.size - 1) { + var x = line[i][0] + var y = line[i][1] + var dx = wrap(line[i + 1][0] - x) * kx + var dy = (line[i + 1][1] - y) * ky + var t = 0.0 + + if (dx != 0.0 || dy != 0.0) { + t = (wrap(p[0] - x) * kx * dx + (p[1] - y) * ky * dy) / (dx * dx + dy * dy) + + if (t > 1) { + x = line[i + 1][0] + y = line[i + 1][1] + } else if (t > 0) { + x += (dx / kx) * t + y += (dy / ky) * t + } + } + + dx = wrap(p[0] - x) * kx + dy = (p[1] - y) * ky + + val sqDist = dx * dx + dy * dy + if (sqDist < minDist) { + minDist = sqDist + minX = x + minY = y + minI = i + minT = t + } + } + + return PointOnLine(doubleArrayOf(minX, minY), minI, minT.coerceIn(0.0, 1.0)) + } + + private fun wrap(degIn: Double): Double { + var deg = degIn + while (deg < -180) deg += 360 + while (deg > 180) deg -= 360 + return deg + } + + data class PointOnLine(val point: Coord, val index: Int, val t: Double) + + private companion object { + const val RE = 6378.137 // equatorial radius, km + const val FE = 1 / 298.257223563 // flattening + const val E2 = FE * (2 - FE) + const val RAD = PI / 180 + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/geometry/ClassifyRings.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/geometry/ClassifyRings.kt new file mode 100644 index 00000000..6aff466a --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/geometry/ClassifyRings.kt @@ -0,0 +1,52 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.geometry + +import ovh.plrapps.mapcompose.vector.spec.style.expression.Point2D + +/** + * Classifies an array of rings into polygons with outer rings and holes. + * + * Ported from `maplibre-style-spec/src/util/classify_rings.ts`. The `maxRings` pruning upstream + * performs (a quickselect over ring areas, needed only to bound earcut's cost) is omitted: the only + * caller here passes `maxRings = 0`, which disables it. + */ +fun classifyRings(rings: List>): List>> { + if (rings.size <= 1) return listOf(rings) + + val polygons = mutableListOf>>() + var polygon: MutableList>? = null + var ccw: Boolean? = null + + for (ring in rings) { + val area = calculateSignedArea(ring) + if (area == 0.0) continue + + if (ccw == null) ccw = area < 0 + + if (ccw == (area < 0)) { + polygon?.let { polygons.add(it) } + polygon = mutableListOf(ring) + } else { + polygon?.add(ring) + } + } + polygon?.let { polygons.add(it) } + + return polygons +} + +/** + * Signed area of a polygon ring. Positive areas are exterior rings with clockwise winding; + * negative areas are interior rings wound counter-clockwise. + */ +private fun calculateSignedArea(ring: List): Double { + var sum = 0.0 + val len = ring.size + var j = len - 1 + for (i in 0 until len) { + val p1 = ring[i] + val p2 = ring[j] + sum += (p2.x - p1.x) * (p1.y + p2.y) + j = i + } + return sum +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/geometry/GeometryUtil.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/geometry/GeometryUtil.kt new file mode 100644 index 00000000..038a2245 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/geometry/GeometryUtil.kt @@ -0,0 +1,149 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.geometry + +import ovh.plrapps.mapcompose.vector.spec.style.expression.CanonicalTileId +import kotlin.math.PI +import kotlin.math.atan +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.round +import kotlin.math.tan + +/** + * Geometry helpers for the `within` and `distance` expressions. + * + * Ported from `maplibre-style-spec/src/util/geometry_util.ts`. All coordinates are in *global* + * tile units (`tileX * EXTENT + localX`), which is what lets a polygon given in lng/lat be compared + * against MVT feature geometry. + */ + +const val EXTENT = 8192 + +/** `[minX, minY, maxX, maxY]`. */ +typealias BBox = DoubleArray + +fun newBBox(): BBox = doubleArrayOf( + Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY, +) + +/** A 2-element `[x, y]` coordinate. */ +typealias Coord = DoubleArray + +private fun mercatorXfromLng(lng: Double): Double = (180 + lng) / 360 + +private fun mercatorYfromLat(lat: Double): Double = + (180 - (180 / PI) * ln(tan(PI / 4 + (lat * PI) / 360))) / 360 + +private fun lngFromMercatorX(mercatorX: Double): Double = mercatorX * 360 - 180 + +private fun latFromMercatorY(mercatorY: Double): Double = + (360 / PI) * atan(exp(((180 - mercatorY * 360) * PI) / 180)) - 90 + +fun getTileCoordinates(position: List, canonical: CanonicalTileId): Coord { + val x = mercatorXfromLng(position[0]) + val y = mercatorYfromLat(position[1]) + val tilesAtZoom = (1 shl canonical.z).toDouble() + return doubleArrayOf(round(x * tilesAtZoom * EXTENT), round(y * tilesAtZoom * EXTENT)) +} + +fun getLngLatFromTileCoord(coord: Coord, canonical: CanonicalTileId): List { + val tilesAtZoom = (1 shl canonical.z).toDouble() + val x = (coord[0] / EXTENT + canonical.x) / tilesAtZoom + val y = (coord[1] / EXTENT + canonical.y) / tilesAtZoom + return listOf(lngFromMercatorX(x), latFromMercatorY(y)) +} + +fun updateBBox(bbox: BBox, coord: Coord) { + bbox[0] = minOf(bbox[0], coord[0]) + bbox[1] = minOf(bbox[1], coord[1]) + bbox[2] = maxOf(bbox[2], coord[0]) + bbox[3] = maxOf(bbox[3], coord[1]) +} + +fun boxWithinBox(bbox1: BBox, bbox2: BBox): Boolean { + if (bbox1[0] <= bbox2[0]) return false + if (bbox1[2] >= bbox2[2]) return false + if (bbox1[1] <= bbox2[1]) return false + if (bbox1[3] >= bbox2[3]) return false + return true +} + +fun rayIntersect(p: Coord, p1: Coord, p2: Coord): Boolean = + (p1[1] > p[1]) != (p2[1] > p[1]) && + p[0] < ((p2[0] - p1[0]) * (p[1] - p1[1])) / (p2[1] - p1[1]) + p1[0] + +private fun pointOnBoundary(p: Coord, p1: Coord, p2: Coord): Boolean { + val x1 = p[0] - p1[0] + val y1 = p[1] - p1[1] + val x2 = p[0] - p2[0] + val y2 = p[1] - p2[1] + return x1 * y2 - x2 * y1 == 0.0 && x1 * x2 <= 0 && y1 * y2 <= 0 +} + +private fun perp(v1: Coord, v2: Coord): Double = v1[0] * v2[1] - v1[1] * v2[0] + +/** Whether p1 and p2 lie on different sides of the segment q1->q2. */ +private fun twoSided(p1: Coord, p2: Coord, q1: Coord, q2: Coord): Boolean { + val x1 = p1[0] - q1[0] + val y1 = p1[1] - q1[1] + val x2 = p2[0] - q1[0] + val y2 = p2[1] - q1[1] + val x3 = q2[0] - q1[0] + val y3 = q2[1] - q1[1] + val det1 = x1 * y3 - x3 * y1 + val det2 = x2 * y3 - x3 * y2 + return (det1 > 0 && det2 < 0) || (det1 < 0 && det2 > 0) +} + +fun segmentIntersectSegment(a: Coord, b: Coord, c: Coord, d: Coord): Boolean { + // Two parallel segments never intersect. The precondition is that a and b are inside the + // polygon, so a segment parallel to an edge cannot cross it. + val vectorP = doubleArrayOf(b[0] - a[0], b[1] - a[1]) + val vectorQ = doubleArrayOf(d[0] - c[0], d[1] - c[1]) + if (perp(vectorQ, vectorP) == 0.0) return false + + return twoSided(a, b, c, d) && twoSided(c, d, a, b) +} + +fun lineIntersectPolygon(p1: Coord, p2: Coord, polygon: List>): Boolean { + for (ring in polygon) { + for (j in 0 until ring.size - 1) { + if (segmentIntersectSegment(p1, p2, ring[j], ring[j + 1])) return true + } + } + return false +} + +/** Ray-casting point-in-polygon test. */ +fun pointWithinPolygon( + point: Coord, + rings: List>, + trueIfOnBoundary: Boolean = false, +): Boolean { + var inside = false + for (ring in rings) { + for (j in 0 until ring.size - 1) { + if (pointOnBoundary(point, ring[j], ring[j + 1])) return trueIfOnBoundary + if (rayIntersect(point, ring[j], ring[j + 1])) inside = !inside + } + } + return inside +} + +fun pointWithinPolygons(point: Coord, polygons: List>>): Boolean = + polygons.any { pointWithinPolygon(point, it) } + +fun lineStringWithinPolygon(line: List, polygon: List>): Boolean { + // First, every vertex of the line must be inside the polygon. + for (point in line) { + if (!pointWithinPolygon(point, polygon)) return false + } + // Second, no segment may cross a polygon edge. + for (i in 0 until line.size - 1) { + if (lineIntersectPolygon(line[i], line[i + 1], polygon)) return false + } + return true +} + +fun lineStringWithinPolygons(line: List, polygons: List>>): Boolean = + polygons.any { lineStringWithinPolygon(line, it) } diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/geometry/TinyQueue.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/geometry/TinyQueue.kt new file mode 100644 index 00000000..768d3171 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/geometry/TinyQueue.kt @@ -0,0 +1,71 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.geometry + +/** + * Binary heap, ported from the `tinyqueue` package MapLibre depends on. + * + * [pop] returns the element that sorts *first* under [comparator]. The `distance` expression + * deliberately supplies a descending comparator, so its queue yields the largest bbox distance + * first; that behaviour is preserved here. + */ +class TinyQueue( + initial: List = emptyList(), + private val comparator: Comparator, +) { + private val data: MutableList = initial.toMutableList() + + val size: Int get() = data.size + + init { + if (data.size > 1) { + for (i in (data.size / 2 - 1) downTo 0) down(i) + } + } + + fun push(item: T) { + data.add(item) + up(data.size - 1) + } + + fun pop(): T? { + if (data.isEmpty()) return null + val top = data[0] + val bottom = data.removeAt(data.size - 1) + if (data.isNotEmpty()) { + data[0] = bottom + down(0) + } + return top + } + + private fun up(posIn: Int) { + var pos = posIn + val item = data[pos] + while (pos > 0) { + val parent = (pos - 1) shr 1 + val current = data[parent] + if (comparator.compare(item, current) >= 0) break + data[pos] = current + pos = parent + } + data[pos] = item + } + + private fun down(posIn: Int) { + var pos = posIn + val halfLength = data.size shr 1 + val item = data[pos] + + while (pos < halfLength) { + var bestChild = (pos shl 1) + 1 + val right = bestChild + 1 + if (right < data.size && comparator.compare(data[right], data[bestChild]) < 0) { + bestChild = right + } + if (comparator.compare(data[bestChild], item) >= 0) break + data[pos] = data[bestChild] + pos = bestChild + } + + data[pos] = item + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/types/Collator.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/types/Collator.kt new file mode 100644 index 00000000..4864777c --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/types/Collator.kt @@ -0,0 +1,136 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.types + +/** + * Ported from `maplibre-style-spec/src/expression/types/collator.ts`. + * + * Divergence: Kotlin Multiplatform has no ICU, so there is no `Intl.Collator` equivalent. This + * implementation is locale-agnostic: + * - case-insensitive comparison uses [String.lowercase] + * - diacritic-insensitive comparison strips combining marks from the Unicode range U+0300..U+036F + * after NFD-like decomposition of the Latin-1 range (a lookup table, not full NFD) + * - [resolvedLocale] echoes back the requested locale, or "en" when none was given + */ +class Collator( + private val caseSensitive: Boolean, + private val diacriticSensitive: Boolean, + val locale: String?, +) { + val sensitivity: String = if (caseSensitive) { + if (diacriticSensitive) "variant" else "case" + } else { + if (diacriticSensitive) "accent" else "base" + } + + /** + * Compares at up to three ICU-style strength levels. + * + * - **Primary** always: diacritics stripped and case folded, so "\u00e4" sorts next to "a" and + * before "b" rather than after "z" as raw code-point order would have it. + * - **Secondary** only when [diacriticSensitive]: accented forms sort after their plain base. + * - **Tertiary** only when [caseSensitive]: lowercase sorts before uppercase, as ICU does. + * + * Locale-tailored rules (German "\u00fc" == "ue", Swedish "\u00e4" after "z") are out of reach without ICU; + * see the class KDoc. + */ + fun compare(lhs: String, rhs: String): Int { + val primary = fold(lhs).compareTo(fold(rhs)) + if (primary != 0) return primary + + if (diacriticSensitive) { + val secondary = lhs.lowercase().compareTo(rhs.lowercase()) + if (secondary != 0) return secondary + } + + if (caseSensitive) { + val tertiary = compareCase(lhs, rhs) + if (tertiary != 0) return tertiary + } + + return 0 + } + + /** The primary key: diacritics stripped and case folded, regardless of sensitivity. */ + private fun fold(s: String): String = stripDiacritics(s).lowercase() + + /** ICU orders lowercase before uppercase at the tertiary level; raw code points do the reverse. */ + private fun compareCase(lhs: String, rhs: String): Int { + val n = minOf(lhs.length, rhs.length) + for (i in 0 until n) { + val a = if (lhs[i].isUpperCase()) 1 else 0 + val b = if (rhs[i].isUpperCase()) 1 else 0 + if (a != b) return a - b + } + return lhs.length - rhs.length + } + + fun resolvedLocale(): String = locale ?: "en" + + override fun equals(other: Any?): Boolean = other is Collator && + other.caseSensitive == caseSensitive && + other.diacriticSensitive == diacriticSensitive && + other.locale == locale + + override fun hashCode(): Int { + var result = caseSensitive.hashCode() + result = 31 * result + diacriticSensitive.hashCode() + result = 31 * result + (locale?.hashCode() ?: 0) + return result + } + + override fun toString(): String = "collator($sensitivity, ${locale ?: ""})" + + private companion object { + /** + * Latin-1 Supplement, Latin Extended-A/B and Latin Extended Additional folded to their + * unaccented ASCII base letter. Generated from Unicode NFD decomposition; characters + * outside these blocks are left untouched, as are decompositions that are not a single + * ASCII letter (\u00df, \u00e6, \u0111 and friends). + */ + private const val ACCENTED = + "\u00c0\u00c1\u00c2\u00c3\u00c4\u00c5\u00c7\u00c8\u00c9\u00ca\u00cb\u00cc\u00cd\u00ce\u00cf\u00d1\u00d2\u00d3\u00d4\u00d5\u00d6\u00d9\u00da\u00db\u00dc\u00dd\u00e0\u00e1\u00e2\u00e3\u00e4\u00e5" + + "\u00e7\u00e8\u00e9\u00ea\u00eb\u00ec\u00ed\u00ee\u00ef\u00f1\u00f2\u00f3\u00f4\u00f5\u00f6\u00f9\u00fa\u00fb\u00fc\u00fd\u00ff\u0100\u0101\u0102\u0103\u0104\u0105\u0106\u0107\u0108\u0109\u010a" + + "\u010b\u010c\u010d\u010e\u010f\u0112\u0113\u0114\u0115\u0116\u0117\u0118\u0119\u011a\u011b\u011c\u011d\u011e\u011f\u0120\u0121\u0122\u0123\u0124\u0125\u0128\u0129\u012a\u012b\u012c\u012d\u012e" + + "\u012f\u0130\u0134\u0135\u0136\u0137\u0139\u013a\u013b\u013c\u013d\u013e\u0143\u0144\u0145\u0146\u0147\u0148\u014c\u014d\u014e\u014f\u0150\u0151\u0154\u0155\u0156\u0157\u0158\u0159\u015a\u015b" + + "\u015c\u015d\u015e\u015f\u0160\u0161\u0162\u0163\u0164\u0165\u0168\u0169\u016a\u016b\u016c\u016d\u016e\u016f\u0170\u0171\u0172\u0173\u0174\u0175\u0176\u0177\u0178\u0179\u017a\u017b\u017c\u017d" + + "\u017e\u01a0\u01a1\u01af\u01b0\u01cd\u01ce\u01cf\u01d0\u01d1\u01d2\u01d3\u01d4\u01d5\u01d6\u01d7\u01d8\u01d9\u01da\u01db\u01dc\u01de\u01df\u01e0\u01e1\u01e6\u01e7\u01e8\u01e9\u01ea\u01eb\u01ec" + + "\u01ed\u01f0\u01f4\u01f5\u01f8\u01f9\u01fa\u01fb\u0200\u0201\u0202\u0203\u0204\u0205\u0206\u0207\u0208\u0209\u020a\u020b\u020c\u020d\u020e\u020f\u0210\u0211\u0212\u0213\u0214\u0215\u0216\u0217" + + "\u0218\u0219\u021a\u021b\u021e\u021f\u0226\u0227\u0228\u0229\u022a\u022b\u022c\u022d\u022e\u022f\u0230\u0231\u0232\u0233\u1e00\u1e01\u1e02\u1e03\u1e04\u1e05\u1e06\u1e07\u1e08\u1e09\u1e0a\u1e0b" + + "\u1e0c\u1e0d\u1e0e\u1e0f\u1e10\u1e11\u1e12\u1e13\u1e14\u1e15\u1e16\u1e17\u1e18\u1e19\u1e1a\u1e1b\u1e1c\u1e1d\u1e1e\u1e1f\u1e20\u1e21\u1e22\u1e23\u1e24\u1e25\u1e26\u1e27\u1e28\u1e29\u1e2a\u1e2b" + + "\u1e2c\u1e2d\u1e2e\u1e2f\u1e30\u1e31\u1e32\u1e33\u1e34\u1e35\u1e36\u1e37\u1e38\u1e39\u1e3a\u1e3b\u1e3c\u1e3d\u1e3e\u1e3f\u1e40\u1e41\u1e42\u1e43\u1e44\u1e45\u1e46\u1e47\u1e48\u1e49\u1e4a\u1e4b" + + "\u1e4c\u1e4d\u1e4e\u1e4f\u1e50\u1e51\u1e52\u1e53\u1e54\u1e55\u1e56\u1e57\u1e58\u1e59\u1e5a\u1e5b\u1e5c\u1e5d\u1e5e\u1e5f\u1e60\u1e61\u1e62\u1e63\u1e64\u1e65\u1e66\u1e67\u1e68\u1e69\u1e6a\u1e6b" + + "\u1e6c\u1e6d\u1e6e\u1e6f\u1e70\u1e71\u1e72\u1e73\u1e74\u1e75\u1e76\u1e77\u1e78\u1e79\u1e7a\u1e7b\u1e7c\u1e7d\u1e7e\u1e7f\u1e80\u1e81\u1e82\u1e83\u1e84\u1e85\u1e86\u1e87\u1e88\u1e89\u1e8a\u1e8b" + + "\u1e8c\u1e8d\u1e8e\u1e8f\u1e90\u1e91\u1e92\u1e93\u1e94\u1e95\u1e96\u1e97\u1e98\u1e99\u1ea0\u1ea1\u1ea2\u1ea3\u1ea4\u1ea5\u1ea6\u1ea7\u1ea8\u1ea9\u1eaa\u1eab\u1eac\u1ead\u1eae\u1eaf\u1eb0\u1eb1" + + "\u1eb2\u1eb3\u1eb4\u1eb5\u1eb6\u1eb7\u1eb8\u1eb9\u1eba\u1ebb\u1ebc\u1ebd\u1ebe\u1ebf\u1ec0\u1ec1\u1ec2\u1ec3\u1ec4\u1ec5\u1ec6\u1ec7\u1ec8\u1ec9\u1eca\u1ecb\u1ecc\u1ecd\u1ece\u1ecf\u1ed0\u1ed1" + + "\u1ed2\u1ed3\u1ed4\u1ed5\u1ed6\u1ed7\u1ed8\u1ed9\u1eda\u1edb\u1edc\u1edd\u1ede\u1edf\u1ee0\u1ee1\u1ee2\u1ee3\u1ee4\u1ee5\u1ee6\u1ee7\u1ee8\u1ee9\u1eea\u1eeb\u1eec\u1eed\u1eee\u1eef\u1ef0\u1ef1" + + "\u1ef2\u1ef3\u1ef4\u1ef5\u1ef6\u1ef7\u1ef8\u1ef9" + + private const val PLAIN = + "AAAAAACEEEEIIIINOOOOOUUUUYaaaaaa" + + "ceeeeiiiinooooouuuuyyAaAaAaCcCcC" + + "cCcDdEeEeEeEeEeGgGgGgGgHhIiIiIiI" + + "iIJjKkLlLlLlNnNnNnOoOoOoRrRrRrSs" + + "SsSsSsTtTtUuUuUuUuUuUuWwYyYZzZzZ" + + "zOoUuAaIiOoUuUuUuUuUuAaAaGgKkOoO" + + "ojGgNnAaAaAaEeEeIiIiOoOoRrRrUuUu" + + "SsTtHhAaEeOoOoOoOoYyAaBbBbBbCcDd" + + "DdDdDdDdEeEeEeEeEeFfGgHhHhHhHhHh" + + "IiIiKkKkKkLlLlLlLlMmMmMmNnNnNnNn" + + "OoOoOoOoPpPpRrRrRrRrSsSsSsSsSsTt" + + "TtTtTtUuUuUuUuUuVvVvWwWwWwWwWwXx" + + "XxYyZzZzZzhtwyAaAaAaAaAaAaAaAaAa" + + "AaAaAaEeEeEeEeEeEeEeEeIiIiOoOoOo" + + "OoOoOoOoOoOoOoOoOoUuUuUuUuUuUuUu" + + "YyYyYyYy" + + fun stripDiacritics(s: String): String { + if (s.all { it.code < 0x00C0 }) return s + val sb = StringBuilder(s.length) + for (ch in s) { + if (ch.code in 0x0300..0x036F) continue // combining marks + val idx = ACCENTED.indexOf(ch) + sb.append(if (idx >= 0) PLAIN[idx] else ch) + } + return sb.toString() + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/types/Formatted.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/types/Formatted.kt new file mode 100644 index 00000000..1808df16 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/types/Formatted.kt @@ -0,0 +1,42 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.types + +import androidx.compose.ui.graphics.Color + +/** Ported from `maplibre-style-spec/src/expression/types/formatted.ts`. */ +enum class VerticalAlign(val value: String) { + BOTTOM("bottom"), CENTER("center"), TOP("top"); + + companion object { + fun fromStringOrNull(value: String?): VerticalAlign? = entries.firstOrNull { it.value == value } + } +} + +data class FormattedSection( + val text: String, + val image: ResolvedImage? = null, + val scale: Double? = null, + val fontStack: String? = null, + val textColor: Color? = null, + val verticalAlign: VerticalAlign? = null, +) + +data class Formatted(val sections: List) { + + fun isEmpty(): Boolean { + if (sections.isEmpty()) return true + return sections.none { it.text.isNotEmpty() || (it.image != null && it.image.name.isNotEmpty()) } + } + + /** Concatenates the section texts, which is what the symbol renderer consumes today. */ + override fun toString(): String = sections.joinToString(separator = "") { it.text } + + companion object { + fun fromString(unformatted: String): Formatted = + Formatted(listOf(FormattedSection(text = unformatted))) + + fun factory(text: Any?): Formatted = when (text) { + is Formatted -> text + else -> fromString(text?.toString() ?: "") + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/types/ResolvedImage.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/types/ResolvedImage.kt new file mode 100644 index 00000000..a800296d --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/types/ResolvedImage.kt @@ -0,0 +1,14 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression.types + +/** Ported from `maplibre-style-spec/src/expression/types/resolved_image.ts`. */ +data class ResolvedImage(val name: String, val available: Boolean) { + override fun toString(): String = name + + companion object { + /** Treats empty values as "no image", like upstream. */ + fun fromString(name: String?): ResolvedImage? { + if (name.isNullOrEmpty()) return null + return ResolvedImage(name = name, available = false) + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/fill/FillLayout.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/fill/FillLayout.kt new file mode 100644 index 00000000..9aac4f78 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/fill/FillLayout.kt @@ -0,0 +1,14 @@ +package ovh.plrapps.mapcompose.vector.spec.style.fill + +import ovh.plrapps.mapcompose.vector.spec.style.LayoutInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FillLayout( + @SerialName("fill-sort-key") + val fillSortKey: ExpressionOrValue? = null, + + override val visibility: String? = "visible" +) : LayoutInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/fill/FillPaint.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/fill/FillPaint.kt new file mode 100644 index 00000000..60edf6b5 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/fill/FillPaint.kt @@ -0,0 +1,34 @@ +package ovh.plrapps.mapcompose.vector.spec.style.fill + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.PaintInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValueColorSerializer + +@Serializable +data class FillPaint( + @SerialName("fill-antialias") + val fillAntialias: ExpressionOrValue? = null, + + @SerialName("fill-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val fillColor: ExpressionOrValue? = null, + + @SerialName("fill-opacity") + val fillOpacity: ExpressionOrValue? = null, + + @SerialName("fill-outline-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val fillOutlineColor: ExpressionOrValue? = null, + + @SerialName("fill-pattern") + val fillPattern: ExpressionOrValue? = null, + + @SerialName("fill-translate") + val fillTranslate: ExpressionOrValue>? = null, + + @SerialName("fill-translate-anchor") + val fillTranslateAnchor: ExpressionOrValue? = null +) : PaintInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/fillExtrusion/FillExtrusionLayout.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/fillExtrusion/FillExtrusionLayout.kt new file mode 100644 index 00000000..d3e1676e --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/fillExtrusion/FillExtrusionLayout.kt @@ -0,0 +1,10 @@ +package ovh.plrapps.mapcompose.vector.spec.style.fillExtrusion + +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.LayoutInterface + +@Serializable +data class FillExtrusionLayout( + override val visibility: String? = "visible" + +) : LayoutInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/fillExtrusion/FillExtrusionPaint.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/fillExtrusion/FillExtrusionPaint.kt new file mode 100644 index 00000000..e6f7594e --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/fillExtrusion/FillExtrusionPaint.kt @@ -0,0 +1,33 @@ +package ovh.plrapps.mapcompose.vector.spec.style.fillExtrusion + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.PaintInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue + +@Serializable +data class FillExtrusionPaint( + @SerialName("fill-extrusion-opacity") + val fillExtrusionOpacity: ExpressionOrValue? = null, + + @SerialName("fill-extrusion-color") + val fillExtrusionColor: ExpressionOrValue? = null, + + @SerialName("fill-extrusion-translate") + val fillExtrusionTranslate: ExpressionOrValue>? = null, + + @SerialName("fill-extrusion-translate-anchor") + val fillExtrusionTranslateAnchor: ExpressionOrValue? = null, + + @SerialName("fill-extrusion-pattern") + val fillExtrusionPattern: ExpressionOrValue? = null, + + @SerialName("fill-extrusion-height") + val fillExtrusionHeight: ExpressionOrValue? = null, + + @SerialName("fill-extrusion-base") + val fillExtrusionBase: ExpressionOrValue? = null, + + @SerialName("fill-extrusion-vertical-gradient") + val fillExtrusionVerticalGradient: ExpressionOrValue? = null +) : PaintInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/filter/FeatureFilter.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/filter/FeatureFilter.kt new file mode 100644 index 00000000..89e1d681 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/filter/FeatureFilter.kt @@ -0,0 +1,339 @@ +package ovh.plrapps.mapcompose.vector.spec.style.filter + +import ovh.plrapps.mapcompose.vector.spec.style.expression.CanonicalTileId +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExpressionLogger +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExpressionResult +import ovh.plrapps.mapcompose.vector.spec.style.expression.GlobalProperties +import ovh.plrapps.mapcompose.vector.spec.style.expression.StyleExpression +import ovh.plrapps.mapcompose.vector.spec.style.expression.StylePropertySpec +import ovh.plrapps.mapcompose.vector.spec.style.expression.createExpression +import ovh.plrapps.mapcompose.vector.spec.style.expression.findGlobalStateRefs +import ovh.plrapps.mapcompose.vector.spec.style.expression.valueToJsonString + +/** + * A compiled layer filter. + * + * Ported from `maplibre-style-spec/src/feature_filter/index.ts`. A filter is nothing more than a + * boolean expression: legacy (v7) filter syntax is rewritten to expression syntax by + * [convertLegacyFilter], then compiled by the ordinary expression parser. That is why every + * operator the expression engine gains is immediately available in filters. + */ +class FeatureFilter( + private val expression: StyleExpression, + /** Whether evaluating this filter needs decoded feature geometry (`within` / `distance`). */ + val needGeometry: Boolean, + private val globalStateRefs: Set, +) { + fun filter( + globals: GlobalProperties, + feature: EvalFeature, + canonical: CanonicalTileId? = null, + ): Boolean = expression.evaluate(globals, feature, canonical = canonical) as? Boolean ?: false + + fun getGlobalStateRefs(): Set = globalStateRefs + + companion object { + /** A filter that accepts everything, used when a layer has no `filter`. */ + fun alwaysTrue(): FeatureFilter? = null + } +} + +/** Which of the two filter syntaxes a filter node belongs to. */ +enum class FilterClassification { + /** Only valid as an expression, e.g. `["==", ["get", "x"], 1]`. */ + EXPRESSION, + + /** Only valid as a deprecated filter, e.g. `["!in", "x", 1]`. */ + LEGACY, + + /** + * Valid as both *and* meaning the same thing in each, e.g. `["has", "x"]` or a bare boolean. + * Reading it either way gives the same result, so it is no evidence of which syntax the author + * was writing, and it must never decide the syntax of the tree it sits in. + */ + NEUTRAL, +} + +private fun classifyChildren(children: List): FilterClassification { + var sawLegacy = false + for (child in children) { + when (classifyFilter(child)) { + // A single expression-only child settles it for the whole tree. + FilterClassification.EXPRESSION -> return FilterClassification.EXPRESSION + FilterClassification.LEGACY -> sawLegacy = true + FilterClassification.NEUTRAL -> Unit + } + } + return if (sawLegacy) FilterClassification.LEGACY else FilterClassification.NEUTRAL +} + +/** + * Note that "parses as both" is not enough to be [FilterClassification.NEUTRAL]: + * `["in", "name", ""]` parses as both, but legacy reads it as a property test and an expression + * reads it as a substring test, so the two disagree. A node whose readings disagree stays legacy. + */ +fun classifyFilter(filter: Any?): FilterClassification { + if (filter is Boolean) return FilterClassification.NEUTRAL + + if (filter !is List<*> || filter.isEmpty()) return FilterClassification.LEGACY + + return when (filter[0]) { + "has" -> { + if (filter.size < 2 || filter[1] == "\$id" || filter[1] == "\$type") { + FilterClassification.LEGACY + } else { + // Both syntaxes read the two-element form as "this property is present"; only the + // expression takes a third argument. + if (filter.size == 2) FilterClassification.NEUTRAL else FilterClassification.EXPRESSION + } + } + + "in" -> { + // Legacy `["in", key, ...values]` tests a property against a set, while the `in` + // expression tests for a substring, so the scalar form is a conflict rather than a + // neutral node. + if (filter.size >= 3 && (filter[1] !is String || filter[2] is List<*>)) { + FilterClassification.EXPRESSION + } else { + FilterClassification.LEGACY + } + } + + "!in", "!has", "none" -> FilterClassification.LEGACY + + "==", "!=", ">", ">=", "<", "<=" -> + if (filter.size != 3 || filter[1] is List<*> || filter[2] is List<*>) { + FilterClassification.EXPRESSION + } else { + FilterClassification.LEGACY + } + + "any", "all" -> classifyChildren(filter.drop(1)) + + else -> FilterClassification.EXPRESSION + } +} + +fun isExpressionFilter(filter: Any?): Boolean = classifyFilter(filter) != FilterClassification.LEGACY + +private fun getFilterPropertyExpression(property: String): Any = when (property) { + "\$type" -> listOf("geometry-type") + "\$id" -> listOf("id") + else -> listOf("get", property) +} + +private fun getLegacyFilterExpressionSuggestion(filter: List): Any? = when (filter[0]) { + "==", "!=", "<", "<=", ">", ">=" -> { + if (filter.size != 3 || filter[1] !is String) null + else listOf(filter[0], getFilterPropertyExpression(filter[1] as String), filter[2]) + } + + "in", "!in" -> { + if (filter.size < 2 || filter[1] !is String) { + null + } else { + val expression = listOf( + "in", + getFilterPropertyExpression(filter[1] as String), + listOf("literal", filter.drop(2)), + ) + if (filter[0] == "!in") listOf("!", expression) else expression + } + } + + "has", "!has" -> { + if (filter.size != 2 || filter[1] !is String) { + null + } else if (filter[1] == "\$type" || filter[1] == "\$id") { + null + } else { + val expression = listOf("has", filter[1]) + if (filter[0] == "!has") listOf("!", expression) else expression + } + } + + else -> null +} + +fun getMixedFilterMessage(filter: List): String { + if (filter[0] in listOf("<", "<=", ">", ">=") && filter.getOrNull(1) == "\$type") { + return "\"\$type\" cannot be use with operator \"${filter[0]}\"" + } + + val suggestion = getLegacyFilterExpressionSuggestion(filter) + return if (suggestion != null) { + "Mixing deprecated filter syntax with expression syntax is not supported. " + + "Replace ${valueToJsonString(filter)} with ${valueToJsonString(suggestion)}." + } else { + "Mixing deprecated filter syntax with expression syntax is not supported. " + + "Convert ${valueToJsonString(filter)} to expression syntax." + } +} + +/** A legacy filter node found nested inside an expression-syntax filter. */ +data class MixedFilterDiagnostic(val path: List, val legacyFilter: List) + +private fun checkChild(index: Int, path: List, filter: List): MixedFilterDiagnostic? { + val child = filter.getOrNull(index) + if (child !is List<*>) return null + if (!isExpressionFilter(child)) { + return MixedFilterDiagnostic(path + index, child) + } + return findMixedLegacyFilter(child, path + index) +} + +fun findMixedLegacyFilter(filter: Any?, path: List = emptyList()): MixedFilterDiagnostic? { + if (filter !is List<*> || filter.isEmpty()) return null + + when (filter[0]) { + "all", "any", "none" -> { + for (i in 1 until filter.size) { + checkChild(i, path, filter)?.let { return it } + } + } + + "!" -> checkChild(1, path, filter)?.let { return it } + + "case" -> { + var i = 1 + while (i < filter.size - 1) { + checkChild(i, path, filter)?.let { return it } + i += 2 + } + } + } + + return null +} + +fun warnAboutMixedLegacyFilter(filter: Any?, rootKey: String) { + val diagnostic = findMixedLegacyFilter(filter) ?: return + val path = diagnostic.path.joinToString(separator = "") { "[$it]" } + ExpressionLogger.warn("$rootKey$path: ${getMixedFilterMessage(diagnostic.legacyFilter)}") +} + +/** Whether the filter reads feature geometry, which is expensive to decode. */ +fun geometryNeeded(filter: Any?): Boolean { + if (filter !is List<*>) return false + if (filter.getOrNull(0) == "within" || filter.getOrNull(0) == "distance") return true + for (index in 1 until filter.size) { + if (geometryNeeded(filter[index])) return true + } + return false +} + +/** + * Compiles a filter, converting legacy syntax first. + * + * Ported from `featureFilter`. Returns `null` for an absent filter (meaning "keep everything") and + * an [ExpressionResult.Error] if the filter fails to compile — callers decide whether to drop the + * layer or the filter. + */ +fun featureFilter( + filter: Any?, + rootKey: String, + globalState: Map? = null, +): ExpressionResult { + if (filter == null) return ExpressionResult.Success(null) + + val expressionFilter = if (!isExpressionFilter(filter)) { + convertLegacyFilter(filter) + } else { + warnAboutMixedLegacyFilter(filter, rootKey) + filter + } + + return when ( + val compiled = createExpression(expressionFilter, rootKey, StylePropertySpec.FILTER, globalState) + ) { + is ExpressionResult.Error -> compiled + is ExpressionResult.Success -> ExpressionResult.Success( + FeatureFilter( + expression = compiled.value, + needGeometry = geometryNeeded(expressionFilter), + globalStateRefs = findGlobalStateRefs(compiled.value.expression), + ) + ) + } +} + +// region legacy conversion + +/** + * Rewrites a legacy (v7) filter into expression syntax. + * + * Ported from the `convertFilter` local to `feature_filter/index.ts` — note this is *not* the + * exported converter in `feature_filter/convert.ts`. It emits the internal `filter-*` operators + * (defined in the expression engine's table), which are specialised fast paths for the legacy + * property/`$id`/`$type` tests. + */ +fun convertLegacyFilter(filter: Any?): Any? { + if (filter == null) return true + if (filter !is List<*>) return true + val op = filter.getOrNull(0) + if (filter.size <= 1) return op != "any" + + return when (op) { + "==" -> convertComparisonOp(filter[1], filter[2], "==") + "!=" -> convertNegation(convertComparisonOp(filter[1], filter[2], "==")) + "<", ">", "<=", ">=" -> convertComparisonOp(filter[1], filter[2], op as String) + "any" -> listOf("any") + filter.drop(1).map { convertLegacyFilter(it) } + "all" -> listOf("all") + filter.drop(1).map { convertLegacyFilter(it) } + "none" -> listOf("all") + filter.drop(1).map { convertNegation(convertLegacyFilter(it)) } + "in" -> convertInOp(filter[1], filter.drop(2)) + "!in" -> convertNegation(convertInOp(filter[1], filter.drop(2))) + "has" -> convertHasOp(filter[1]) + "!has" -> convertNegation(convertHasOp(filter[1])) + else -> true + } +} + +private fun convertComparisonOp(property: Any?, value: Any?, op: String): Any = when (property) { + "\$type" -> listOf("filter-type-$op", value) + "\$id" -> listOf("filter-id-$op", value) + else -> listOf("filter-$op", property, value) +} + +private fun convertInOp(property: Any?, values: List): Any { + if (values.isEmpty()) return false + return when (property) { + "\$type" -> listOf("filter-type-in", listOf("literal", values)) + "\$id" -> listOf("filter-id-in", listOf("literal", values)) + else -> { + val homogeneous = values.all { sameJsType(it, values[0]) } + if (values.size > 200 && homogeneous) { + listOf("filter-in-large", property, listOf("literal", values.sortedWith(JS_VALUE_ORDER))) + } else { + listOf("filter-in-small", property, listOf("literal", values)) + } + } + } +} + +private fun convertHasOp(property: Any?): Any = when (property) { + "\$type" -> true + "\$id" -> listOf("filter-has-id") + else -> listOf("filter-has", property) +} + +private fun convertNegation(filter: Any?): Any = listOf("!", filter) + +private fun sameJsType(a: Any?, b: Any?): Boolean = when { + a is Number && b is Number -> true + a is String && b is String -> true + a is Boolean && b is Boolean -> true + else -> a == null && b == null +} + +/** Orders the homogeneous value list `filter-in-large` binary-searches. */ +private val JS_VALUE_ORDER = Comparator { a, b -> + when { + a is Number && b is Number -> a.toDouble().compareTo(b.toDouble()) + a is String && b is String -> a.compareTo(b) + a is Boolean && b is Boolean -> a.compareTo(b) + else -> 0 + } +} + +// endregion diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/heatmap/HeatmapLayout.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/heatmap/HeatmapLayout.kt new file mode 100644 index 00000000..13cb55f4 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/heatmap/HeatmapLayout.kt @@ -0,0 +1,9 @@ +package ovh.plrapps.mapcompose.vector.spec.style.heatmap + +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.LayoutInterface + +@Serializable +data class HeatmapLayout( + override val visibility: String? = "visible" +) : LayoutInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/heatmap/HeatmapPaint.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/heatmap/HeatmapPaint.kt new file mode 100644 index 00000000..dade0c2a --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/heatmap/HeatmapPaint.kt @@ -0,0 +1,24 @@ +package ovh.plrapps.mapcompose.vector.spec.style.heatmap + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.PaintInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue + +@Serializable +data class HeatmapPaint( + @SerialName("heatmap-weight") + val heatmapWeight: ExpressionOrValue? = null, + + @SerialName("heatmap-intensity") + val heatmapIntensity: ExpressionOrValue? = null, + + @SerialName("heatmap-color") + val heatmapColor: ExpressionOrValue? = null, + + @SerialName("heatmap-radius") + val heatmapRadius: ExpressionOrValue? = null, + + @SerialName("heatmap-opacity") + val heatmapOpacity: ExpressionOrValue? = null +) : PaintInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/hillshade/HillshadeLayout.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/hillshade/HillshadeLayout.kt new file mode 100644 index 00000000..2ad86664 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/hillshade/HillshadeLayout.kt @@ -0,0 +1,9 @@ +package ovh.plrapps.mapcompose.vector.spec.style.hillshade + +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.LayoutInterface + +@Serializable +data class HillshadeLayout( + override val visibility: String? = "visible" +) : LayoutInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/hillshade/HillshadePaint.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/hillshade/HillshadePaint.kt new file mode 100644 index 00000000..103b6582 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/hillshade/HillshadePaint.kt @@ -0,0 +1,32 @@ +package ovh.plrapps.mapcompose.vector.spec.style.hillshade + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.PaintInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValueColorSerializer + +@Serializable +data class HillshadePaint( + @SerialName("hillshade-accent-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val hillshadeAccentColor: ExpressionOrValue? = null, + + @SerialName("hillshade-exaggeration") + val hillshadeExaggeration: ExpressionOrValue? = null, + + @SerialName("hillshade-highlight-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val hillshadeHighlightColor: ExpressionOrValue? = null, + + @SerialName("hillshade-illumination-anchor") + val hillshadeIlluminationAnchor: ExpressionOrValue? = null, + + @SerialName("hillshade-illumination-direction") + val hillshadeIlluminationDirection: ExpressionOrValue? = null, + + @SerialName("hillshade-shadow-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val hillshadeShadowColor: ExpressionOrValue? = null +) : PaintInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/line/LineLayout.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/line/LineLayout.kt new file mode 100644 index 00000000..a40553c3 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/line/LineLayout.kt @@ -0,0 +1,23 @@ +package ovh.plrapps.mapcompose.vector.spec.style.line + +import ovh.plrapps.mapcompose.vector.spec.style.LayoutInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LineLayout( + @SerialName("line-cap") + val lineCap: ExpressionOrValue? = null, + + @SerialName("line-join") + val lineJoin: ExpressionOrValue? = null, + + @SerialName("line-miter-limit") + val lineMiterLimit: ExpressionOrValue? = null, + + @SerialName("line-round-limit") + val lineRoundLimit: ExpressionOrValue? = null, + + override val visibility: String? = "visible" +) : LayoutInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/line/LinePaint.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/line/LinePaint.kt new file mode 100644 index 00000000..0fcf8550 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/line/LinePaint.kt @@ -0,0 +1,42 @@ +package ovh.plrapps.mapcompose.vector.spec.style.line + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.PaintInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValueColorSerializer + +@Serializable +data class LinePaint( + @SerialName("line-opacity") + val lineOpacity: ExpressionOrValue? = null, + + @SerialName("line-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val lineColor: ExpressionOrValue? = null, + + @SerialName("line-width") + val lineWidth: ExpressionOrValue? = null, + + @SerialName("line-offset") + val lineOffset: ExpressionOrValue? = null, + + @SerialName("line-blur") + val lineBlur: ExpressionOrValue? = null, + + @SerialName("line-dasharray") + val lineDasharray: ExpressionOrValue>? = null, + + @SerialName("line-pattern") + val linePattern: ExpressionOrValue? = null, + + @SerialName("line-translate") + val lineTranslate: ExpressionOrValue>? = null, + + @SerialName("line-translate-anchor") + val lineTranslateAnchor: ExpressionOrValue? = null, + + @SerialName("line-gap-width") + val lineGapWidth: ExpressionOrValue? = null +) : PaintInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrColor.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrColor.kt new file mode 100644 index 00000000..7473b9cc --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrColor.kt @@ -0,0 +1,12 @@ +package ovh.plrapps.mapcompose.vector.spec.style.props + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.KSerializer +import ovh.plrapps.mapcompose.vector.spec.style.serializers.ColorSerializer +import ovh.plrapps.mapcompose.vector.spec.style.serializers.ExpressionOrValueSerializer + +val colorSerializer: KSerializer = ColorSerializer + + +object ExpressionOrValueColorSerializer : + KSerializer> by ExpressionOrValueSerializer(colorSerializer) diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrValue.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrValue.kt new file mode 100644 index 00000000..29331b49 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrValue.kt @@ -0,0 +1,168 @@ +package ovh.plrapps.mapcompose.vector.spec.style.props + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import ovh.plrapps.mapcompose.vector.spec.style.expression.CanonicalTileId +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature +import ovh.plrapps.mapcompose.vector.spec.style.expression.GlobalProperties +import ovh.plrapps.mapcompose.vector.spec.style.expression.StylePropertyExpression +import ovh.plrapps.mapcompose.vector.spec.style.expression.isExpression +import ovh.plrapps.mapcompose.vector.spec.style.expression.jsonToValue +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.Formatted +import ovh.plrapps.mapcompose.vector.spec.style.serializers.ExpressionOrValueSerializer + +/** + * A style property value: either a constant or a compiled MapLibre expression. + * + * This is the public façade over the expression engine in + * `ovh.plrapps.mapcompose.vector.spec.style.expression`. Painters only ever go through the + * `processAs*` helpers below, which coerce the engine's `Any?` result to the type the painter + * wants and return `null` when it cannot — letting the painter's `?: default` supply the MapLibre + * spec default, which is how upstream's "fall back to the property default" behaviour is realised + * here. + * + * [source] is the original JSON text, kept so the property can be re-serialized exactly. + */ +@Serializable(with = ExpressionOrValueSerializer::class) +sealed class ExpressionOrValue { + abstract val source: String + + data class Value(val value: T, override val source: String = "") : ExpressionOrValue() + + data class Expression( + val expression: StylePropertyExpression, + override val source: String = "", + ) : ExpressionOrValue() + + /** + * A property whose expression failed to compile. + * + * It always evaluates to `null`, so the painter's `?: default` supplies the MapLibre spec + * default and the layer keeps rendering. The parse errors are in + * `MapLibreConfiguration.diagnostics`. + */ + data class Invalid(override val source: String = "") : ExpressionOrValue() + + /** + * Evaluates the property. + * + * Never throws: a runtime error inside the expression is warned about once and yields the + * property's default (usually `null` here). See `StyleExpression.evaluate`. + */ + @Suppress("UNCHECKED_CAST") + fun process( + feature: EvalFeature? = null, + zoom: Double? = null, + canonical: CanonicalTileId? = null, + availableImages: List? = null, + ): T? = when (this) { + is Value -> value + is Invalid -> null + is Expression -> expression.styleExpression.evaluate( + globals = GlobalProperties(zoom = zoom ?: 0.0), + feature = feature, + canonical = canonical, + availableImages = availableImages, + ) as T? + } + + companion object { + /** + * Whether a JSON value should be parsed as an expression rather than a constant. + * + * An array headed by a known operator name is an expression; a legacy v7 *function object* + * (`{stops}` / `{type}` / `{base}` / `{property}`) is normalized into one before parsing. + */ + fun isExpression(input: JsonElement): Boolean = when (input) { + is JsonArray -> { + val head = input.firstOrNull() + head is JsonPrimitive && head.isString && isExpression(jsonToValue(input)) + } + + is JsonObject -> input.containsKey("stops") || + input.containsKey("type") || + input.containsKey("base") || + input.containsKey("property") + + else -> false + } + } +} + +// region typed accessors +// +// Each helper mirrors what the corresponding painter needs. They deliberately return null rather +// than throwing so the caller's `?:` can supply the MapLibre spec default. + +fun ExpressionOrValue?.processAsDouble( + feature: EvalFeature? = null, + zoom: Double? = null, +): Double? = (this?.processUntyped(feature, zoom) as? Number)?.toDouble() + +fun ExpressionOrValue?.processAsFloat( + feature: EvalFeature? = null, + zoom: Double? = null, +): Float? = (this?.processUntyped(feature, zoom) as? Number)?.toFloat() + +fun ExpressionOrValue?.processAsString( + feature: EvalFeature? = null, + zoom: Double? = null, +): String? = when (val result = this?.processUntyped(feature, zoom)) { + null -> null + is String -> result + // `format` produces a Formatted value; the symbol renderer consumes plain text today. + is Formatted -> result.toString() + else -> result.toString() +} + +fun ExpressionOrValue?.processAsColor( + feature: EvalFeature? = null, + zoom: Double? = null, +): Color? = this?.processUntyped(feature, zoom) as? Color + +fun ExpressionOrValue?.processAsBoolean( + feature: EvalFeature? = null, + zoom: Double? = null, +): Boolean? = when (val result = this?.processUntyped(feature, zoom)) { + is Boolean -> result + is String -> result.toBooleanStrictOrNull() + is Number -> result.toDouble() != 0.0 + else -> null +} + +fun ExpressionOrValue>?.processAsStringList( + feature: EvalFeature? = null, + zoom: Double? = null, +): List? { + val result = this?.processUntyped(feature, zoom) as? List<*> ?: return null + return result.map { it?.toString() ?: "" } +} + +fun ExpressionOrValue>?.processAsDoubleList( + feature: EvalFeature? = null, + zoom: Double? = null, +): List? { + val result = this?.processUntyped(feature, zoom) as? List<*> ?: return null + return result.mapNotNull { (it as? Number)?.toDouble() } +} + +/** + * Evaluates without the declared type parameter getting in the way. The generic `T` on + * [ExpressionOrValue] is nominal — the engine works in `Any?` — so the coercing helpers above go + * through this rather than [ExpressionOrValue.process]. + */ +private fun ExpressionOrValue<*>.processUntyped(feature: EvalFeature?, zoom: Double?): Any? = + when (this) { + is ExpressionOrValue.Value -> value + is ExpressionOrValue.Invalid -> null + is ExpressionOrValue.Expression -> expression.styleExpression.evaluate( + globals = GlobalProperties(zoom = zoom ?: 0.0), + feature = feature, + ) + } + +// endregion diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/raster/RasterLayout.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/raster/RasterLayout.kt new file mode 100644 index 00000000..ab98d94e --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/raster/RasterLayout.kt @@ -0,0 +1,9 @@ +package ovh.plrapps.mapcompose.vector.spec.style.raster + +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.LayoutInterface + +@Serializable +data class RasterLayout( + override val visibility: String? = "visible" +) : LayoutInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/raster/RasterPaint.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/raster/RasterPaint.kt new file mode 100644 index 00000000..4b7e716e --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/raster/RasterPaint.kt @@ -0,0 +1,26 @@ +package ovh.plrapps.mapcompose.vector.spec.style.raster + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.PaintInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue + +@Serializable +data class RasterPaint( + @SerialName("raster-opacity") + val rasterOpacity: ExpressionOrValue? = null, + @SerialName("raster-hue-rotate") + val rasterHueRotate: ExpressionOrValue? = null, + @SerialName("raster-brightness-min") + val rasterBrightnessMin: ExpressionOrValue? = null, + @SerialName("raster-brightness-max") + val rasterBrightnessMax: ExpressionOrValue? = null, + @SerialName("raster-saturation") + val rasterSaturation: ExpressionOrValue? = null, + @SerialName("raster-contrast") + val rasterContrast: ExpressionOrValue? = null, + @SerialName("raster-resampling") + val rasterResampling: ExpressionOrValue? = null, + @SerialName("raster-fade-duration") + val rasterFadeDuration: ExpressionOrValue? = null +) : PaintInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/ColorSerializer.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/ColorSerializer.kt new file mode 100644 index 00000000..d7c120cc --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/ColorSerializer.kt @@ -0,0 +1,23 @@ +package ovh.plrapps.mapcompose.vector.spec.style.serializers + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import ovh.plrapps.mapcompose.vector.spec.style.utils.ColorParser + +object ColorSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Color", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Color) { + encoder.encodeString(ColorParser.colorToHexString(value)) + } + + override fun deserialize(decoder: Decoder): Color { + val string = decoder.decodeString() + return ColorParser.parseColorStringOrNull(string) ?: error("parse color error $string") + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/ExpressionOrValueSerializer.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/ExpressionOrValueSerializer.kt new file mode 100644 index 00000000..bcd5f86f --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/ExpressionOrValueSerializer.kt @@ -0,0 +1,169 @@ +package ovh.plrapps.mapcompose.vector.spec.style.serializers + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PolymorphicKind +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonUnquotedLiteral +import kotlinx.serialization.json.jsonPrimitive +import ovh.plrapps.mapcompose.vector.spec.style.expression.ArrayType +import ovh.plrapps.mapcompose.vector.spec.style.expression.BooleanType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ColorType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExprType +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExpressionResult +import ovh.plrapps.mapcompose.vector.spec.style.expression.NumberType +import ovh.plrapps.mapcompose.vector.spec.style.expression.StringType +import ovh.plrapps.mapcompose.vector.spec.style.expression.StylePropertySpec +import ovh.plrapps.mapcompose.vector.spec.style.expression.ValueType +import ovh.plrapps.mapcompose.vector.spec.style.expression.array +import ovh.plrapps.mapcompose.vector.spec.style.expression.createPropertyExpression +import ovh.plrapps.mapcompose.vector.spec.style.expression.convertLegacyFunction +import ovh.plrapps.mapcompose.vector.spec.style.expression.jsonToValue +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import ovh.plrapps.mapcompose.vector.spec.style.utils.ColorParser +import ovh.plrapps.mapcompose.vector.spec.style.utils.StyleDiagnostics + +/** + * Decodes a style property into an [ExpressionOrValue]. + * + * Expressions are compiled by [createPropertyExpression], so they are type-checked against the + * property's expected type at parse time — that is what lets `["get", "x"]` be used where a number + * is required, and it is why data-driven `interpolate` works without any runtime casting. + * + * Parse errors are **not** thrown: they are recorded in [StyleDiagnostics] and the property decodes + * to a `null`-valued constant, so one bad property never aborts loading the style. + */ +@OptIn(ExperimentalSerializationApi::class) +class ExpressionOrValueSerializer( + private val valueSerializer: KSerializer, +) : KSerializer> { + + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ExpressionOrValue") + + private val propertySpec: StylePropertySpec = expectedTypeOf(valueSerializer).let { expectedType -> + StylePropertySpec( + expectedType = expectedType, + // MapLibre marks only number-, color- and number-array-valued properties as + // interpolatable. The flag decides whether an untyped legacy `{stops}` function becomes + // an `interpolate` or a `step`, so getting it wrong turns e.g. `text-field` stops into + // an "is not interpolatable" parse error. + supportsInterpolation = isInterpolatable(expectedType), + ) + } + + override fun serialize(encoder: Encoder, value: ExpressionOrValue) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException("This serializer can only be used with Json") + + when (value) { + is ExpressionOrValue.Value -> when (valueSerializer) { + is ColorSerializer -> jsonEncoder.encodeJsonElement( + JsonUnquotedLiteral(ColorParser.colorToHexString(value.value as Color)) + ) + + else -> jsonEncoder.encodeJsonElement( + jsonEncoder.json.encodeToJsonElement(valueSerializer, value.value) + ) + } + + // Expressions round-trip through their original JSON text. The parsed form is a + // type-annotated, constant-folded tree that no longer corresponds 1:1 to the input, so + // re-deriving the JSON from it would not be faithful. An Invalid property round-trips + // the same way, so a style that failed to compile still re-serializes unchanged. + is ExpressionOrValue.Expression -> jsonEncoder.encodeJsonElement( + jsonEncoder.json.parseToJsonElement(value.source) + ) + + is ExpressionOrValue.Invalid -> jsonEncoder.encodeJsonElement( + jsonEncoder.json.parseToJsonElement(value.source) + ) + } + } + + override fun deserialize(decoder: Decoder): ExpressionOrValue { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("This serializer can only be used with Json") + val element = jsonDecoder.decodeJsonElement() + val source = element.toString() + + if (ExpressionOrValue.isExpression(element)) { + // Legacy v7 function objects ({stops}, {type}, {property}, {base}) are rewritten into + // expression syntax before parsing. + val parsedValue = jsonToValue(element) + val normalized = if (element is JsonObject) { + @Suppress("UNCHECKED_CAST") + convertLegacyFunction(parsedValue as Map, propertySpec) + } else { + parsedValue + } + + return when (val result = createPropertyExpression(normalized, source, propertySpec)) { + is ExpressionResult.Success -> ExpressionOrValue.Expression(result.value, source = source) + is ExpressionResult.Error -> { + StyleDiagnostics.report(source, result.errors) + ExpressionOrValue.Invalid(source = source) + } + } + } + + return when (valueSerializer) { + is ColorSerializer -> { + val color = ColorParser.parseColorStringOrNull(element.jsonPrimitive.content) + ?: throw SerializationException("Invalid color format ${element.jsonPrimitive.content}") + @Suppress("UNCHECKED_CAST") + ExpressionOrValue.Value(color as T, source = source) + } + + else -> ExpressionOrValue.Value( + jsonDecoder.json.decodeFromJsonElement(valueSerializer, element), + source = source, + ) + } + } + + private companion object { + /** + * Derives the expression type a property expects from its Kotlin value serializer. + * + * Upstream reads this from generated style-spec metadata; MapCompose models properties as + * data classes, so the serializer's descriptor is the available signal. A `null` result + * means "no expected type", which disables the parser's assert/coerce annotation. + */ + fun expectedTypeOf(serializer: KSerializer<*>): ExprType? { + if (serializer is ColorSerializer) return ColorType + return descriptorToType(serializer.descriptor) + } + + fun isInterpolatable(type: ExprType?): Boolean = when (type) { + NumberType, ColorType -> true + is ArrayType -> type.itemType == NumberType + else -> false + } + + fun descriptorToType(descriptor: SerialDescriptor): ExprType? = when (descriptor.kind) { + PrimitiveKind.STRING -> StringType + PrimitiveKind.BOOLEAN -> BooleanType + PrimitiveKind.BYTE, PrimitiveKind.SHORT, PrimitiveKind.INT, PrimitiveKind.LONG, + PrimitiveKind.FLOAT, PrimitiveKind.DOUBLE, + -> NumberType + + StructureKind.LIST -> { + val itemType = runCatching { descriptorToType(descriptor.getElementDescriptor(0)) }.getOrNull() + array(itemType ?: ValueType) + } + + is PolymorphicKind -> null + else -> null + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/FeatureFilterSerializer.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/FeatureFilterSerializer.kt new file mode 100644 index 00000000..78d5bba6 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/FeatureFilterSerializer.kt @@ -0,0 +1,59 @@ +package ovh.plrapps.mapcompose.vector.spec.style.serializers + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExpressionResult +import ovh.plrapps.mapcompose.vector.spec.style.expression.jsonToValue +import ovh.plrapps.mapcompose.vector.spec.style.filter.FeatureFilter +import ovh.plrapps.mapcompose.vector.spec.style.filter.featureFilter +import ovh.plrapps.mapcompose.vector.spec.style.utils.StyleDiagnostics + +/** + * Compiles a layer `filter` at deserialization time. + * + * A filter that fails to compile is reported to [StyleDiagnostics] and decodes to `null`, i.e. "no + * filter". That matches MapLibre's lenient posture: an unusable filter must not hide the layer's + * features, and it must certainly not abort loading the style. + * + * The original JSON is kept on [FilterHolder.source] so the filter can be re-serialized exactly; + * the compiled expression is a type-annotated, constant-folded tree that no longer corresponds 1:1 + * to the input. + */ +object FeatureFilterSerializer : KSerializer { + + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Filter") + + override fun deserialize(decoder: Decoder): FilterHolder { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("FeatureFilterSerializer only works with JSON") + val element = jsonDecoder.decodeJsonElement() + val source = element.toString() + + return when (val result = featureFilter(jsonToValue(element), rootKey = source)) { + is ExpressionResult.Success -> FilterHolder(result.value, source) + is ExpressionResult.Error -> { + StyleDiagnostics.report(source, result.errors) + FilterHolder(null, source) + } + } + } + + override fun serialize(encoder: Encoder, value: FilterHolder) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException("FeatureFilterSerializer only works with JSON") + jsonEncoder.encodeJsonElement(jsonEncoder.json.parseToJsonElement(value.source)) + } +} + +/** + * A compiled [FeatureFilter] plus the JSON it came from. + * + * [filter] is null when the layer's filter failed to compile, which means "keep every feature". + */ +data class FilterHolder(val filter: FeatureFilter?, val source: String) diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/sky/SkyLayout.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/sky/SkyLayout.kt new file mode 100644 index 00000000..89e85415 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/sky/SkyLayout.kt @@ -0,0 +1,9 @@ +package ovh.plrapps.mapcompose.vector.spec.style.sky + +import ovh.plrapps.mapcompose.vector.spec.style.LayoutInterface +import kotlinx.serialization.Serializable + +@Serializable +data class SkyLayout( + override val visibility: String? = "visible" +) : LayoutInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/sky/SkyPaint.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/sky/SkyPaint.kt new file mode 100644 index 00000000..96da218e --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/sky/SkyPaint.kt @@ -0,0 +1,32 @@ +package ovh.plrapps.mapcompose.vector.spec.style.sky + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.PaintInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValueColorSerializer + +@Serializable +data class SkyPaint( + @SerialName("sky-atmosphere-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val skyAtmosphereColor: ExpressionOrValue? = null, + @SerialName("sky-atmosphere-halo-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val skyAtmosphereHaloColor: ExpressionOrValue? = null, + @SerialName("sky-atmosphere-sun-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val skyAtmosphereSunColor: ExpressionOrValue? = null, + @SerialName("sky-atmosphere-sun-intensity") + val skyAtmosphereSunIntensity: ExpressionOrValue? = null, + @SerialName("sky-gradient") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val skyGradient: ExpressionOrValue? = null, + @SerialName("sky-gradient-radius") + val skyGradientRadius: ExpressionOrValue? = null, + @SerialName("sky-opacity") + val skyOpacity: ExpressionOrValue? = null, + @SerialName("sky-type") + val skyType: ExpressionOrValue? = null +) : PaintInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/symbol/SymbolLayout.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/symbol/SymbolLayout.kt new file mode 100644 index 00000000..4b46e83b --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/symbol/SymbolLayout.kt @@ -0,0 +1,140 @@ +package ovh.plrapps.mapcompose.vector.spec.style.symbol + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.LayoutInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue + +@Serializable +data class SymbolLayout( + override val visibility: String? = "visible", + + @SerialName("icon-allow-overlap") + val iconAllowOverlap: ExpressionOrValue? = null, + + @SerialName("icon-ignore-placement") + val iconIgnorePlacement: ExpressionOrValue? = null, + + @SerialName("icon-optional") + val iconOptional: ExpressionOrValue? = null, + + @SerialName("icon-rotation-alignment") + val iconRotationAlignment: ExpressionOrValue? = null, + + @SerialName("icon-size") + val iconSize: ExpressionOrValue? = null, + + @SerialName("icon-text-fit") + val iconTextFit: ExpressionOrValue? = null, + + @SerialName("icon-text-fit-padding") + val iconTextFitPadding: ExpressionOrValue>? = null, + + @SerialName("icon-image") + val iconImage: ExpressionOrValue? = null, + + @SerialName("icon-rotate") + val iconRotate: ExpressionOrValue? = null, + + @SerialName("icon-padding") + val iconPadding: ExpressionOrValue? = null, + + @SerialName("icon-keep-upright") + val iconKeepUpright: ExpressionOrValue? = null, + + @SerialName("icon-offset") + val iconOffset: ExpressionOrValue>? = null, + + @SerialName("icon-anchor") + val iconAnchor: ExpressionOrValue? = null, + + @SerialName("icon-pitch-alignment") + val iconPitchAlignment: ExpressionOrValue? = null, + + @SerialName("text-pitch-alignment") + val textPitchAlignment: ExpressionOrValue? = null, + + @SerialName("text-rotation-alignment") + val textRotationAlignment: ExpressionOrValue? = null, + + @SerialName("text-field") + val textField: ExpressionOrValue? = null, + + @SerialName("text-font") + val textFont: ExpressionOrValue>? = null, + + @SerialName("text-size") + val textSize: ExpressionOrValue? = null, + + @SerialName("text-max-width") + val textMaxWidth: ExpressionOrValue? = null, + + @SerialName("text-line-height") + val textLineHeight: ExpressionOrValue? = null, + + @SerialName("text-letter-spacing") + val textLetterSpacing: ExpressionOrValue? = null, + + @SerialName("text-justify") + val textJustify: ExpressionOrValue? = null, + + @SerialName("text-radial-offset") + val textRadialOffset: ExpressionOrValue? = null, + + @SerialName("text-variable-anchor") + val textVariableAnchor: ExpressionOrValue>? = null, + + @SerialName("text-anchor") + val textAnchor: ExpressionOrValue? = null, + + @SerialName("text-max-angle") + val textMaxAngle: ExpressionOrValue? = null, + + @SerialName("text-writing-mode") + val textWritingMode: ExpressionOrValue>? = null, + + @SerialName("text-rotate") + val textRotate: ExpressionOrValue? = null, + + @SerialName("text-padding") + val textPadding: ExpressionOrValue? = null, + + @SerialName("text-keep-upright") + val textKeepUpright: ExpressionOrValue? = null, + + @SerialName("text-transform") + val textTransform: ExpressionOrValue? = null, + + @SerialName("text-offset") + val textOffset: ExpressionOrValue>? = null, + + @SerialName("text-allow-overlap") + val textAllowOverlap: ExpressionOrValue? = null, + + @SerialName("text-ignore-placement") + val textIgnorePlacement: ExpressionOrValue? = null, + + @SerialName("text-optional") + val textOptional: ExpressionOrValue? = null, + + @SerialName("symbol-placement") + val symbolPlacement: ExpressionOrValue? = null, + + @SerialName("symbol-spacing") + val symbolSpacing: ExpressionOrValue? = null, + + @SerialName("symbol-avoid-edges") + val symbolAvoidEdges: ExpressionOrValue? = null, + + @SerialName("symbol-sort-key") + val symbolSortKey: ExpressionOrValue? = null, + + @SerialName("symbol-z-order") + val symbolZOrder: ExpressionOrValue? = null, + + @SerialName("icon-overlap") + val iconOverlap: ExpressionOrValue? = null, + + @SerialName("text-overlap") + val textOverlap: ExpressionOrValue? = null +) : LayoutInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/symbol/SymbolPaint.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/symbol/SymbolPaint.kt new file mode 100644 index 00000000..248c68ce --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/symbol/SymbolPaint.kt @@ -0,0 +1,45 @@ +package ovh.plrapps.mapcompose.vector.spec.style.symbol + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ovh.plrapps.mapcompose.vector.spec.style.PaintInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValueColorSerializer + +@Serializable +data class SymbolPaint( + @SerialName("icon-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val iconColor: ExpressionOrValue? = null, + + @SerialName("icon-halo-blur") + val iconHaloBlur: ExpressionOrValue? = null, + @SerialName("icon-halo-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val iconHaloColor: ExpressionOrValue? = null, + @SerialName("icon-halo-width") + val iconHaloWidth: ExpressionOrValue? = null, + @SerialName("icon-opacity") + val iconOpacity: ExpressionOrValue? = null, + @SerialName("icon-translate") + val iconTranslate: ExpressionOrValue>? = null, + @SerialName("icon-translate-anchor") + val iconTranslateAnchor: ExpressionOrValue? = null, + @SerialName("text-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val textColor: ExpressionOrValue? = null, + @SerialName("text-halo-blur") + val textHaloBlur: ExpressionOrValue? = null, + @SerialName("text-halo-color") + @Serializable(with = ExpressionOrValueColorSerializer::class) + val textHaloColor: ExpressionOrValue? = null, + @SerialName("text-halo-width") + val textHaloWidth: ExpressionOrValue? = null, + @SerialName("text-opacity") + val textOpacity: ExpressionOrValue? = null, + @SerialName("text-translate") + val textTranslate: ExpressionOrValue>? = null, + @SerialName("text-translate-anchor") + val textTranslateAnchor: ExpressionOrValue? = null +) : PaintInterface \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/symbol/TextAnchor.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/symbol/TextAnchor.kt new file mode 100644 index 00000000..b050b64e --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/symbol/TextAnchor.kt @@ -0,0 +1,44 @@ +package ovh.plrapps.mapcompose.vector.spec.style.symbol + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable(with = TextAnchorSerializer::class) +enum class TextAnchor(val value: String) { + Center("center"), + Left("left"), + Right("right"), + Top("top"), + Bottom("bottom"), + TopLeft("top-left"), + TopRight("top-right"), + BottomLeft("bottom-left"), + BottomRight("bottom-right"); + + companion object { + fun fromString(value: String?): TextAnchor { + return TextAnchor.entries.find { it.value.equals(value, ignoreCase = true) } ?: Center + } + + fun fromAny(value: Any?): TextAnchor? { + return when (value) { + is TextAnchor -> value + is String -> fromString(value) + else -> null + } + } + } +} + +object TextAnchorSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TextAnchor", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): TextAnchor = + TextAnchor.fromString(decoder.decodeString()) + override fun serialize(encoder: Encoder, value: TextAnchor) = + encoder.encodeString(value.value) +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/ColorParser.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/ColorParser.kt new file mode 100644 index 00000000..74cb2eea --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/ColorParser.kt @@ -0,0 +1,289 @@ +package ovh.plrapps.mapcompose.vector.spec.style.utils + +import androidx.compose.ui.graphics.Color +import ovh.plrapps.mapcompose.vector.spec.style.expression.colorspaces.CssColorParser +import androidx.compose.ui.graphics.toArgb +import kotlin.math.roundToInt + +object ColorParser { + private val namedColors = mapOf( + "aliceblue" to Color(0xFFF0F8FF), + "antiquewhite" to Color(0xFFFAEBD7), + "aqua" to Color(0xFF00FFFF), + "aquamarine" to Color(0xFF7FFFD4), + "azure" to Color(0xFFF0FFFF), + "beige" to Color(0xFFF5F5DC), + "bisque" to Color(0xFFFFE4C4), + "black" to Color(0xFF000000), + "blanchedalmond" to Color(0xFFFFEBCD), + "blue" to Color(0xFF0000FF), + "blueviolet" to Color(0xFF8A2BE2), + "brown" to Color(0xFFA52A2A), + "burlywood" to Color(0xFFDEB887), + "cadetblue" to Color(0xFF5F9EA0), + "chartreuse" to Color(0xFF7FFF00), + "chocolate" to Color(0xFFD2691E), + "coral" to Color(0xFFFF7F50), + "cornflowerblue" to Color(0xFF6495ED), + "cornsilk" to Color(0xFFFFF8DC), + "crimson" to Color(0xFFDC143C), + "cyan" to Color(0xFF00FFFF), + "darkblue" to Color(0xFF00008B), + "darkcyan" to Color(0xFF008B8B), + "darkgoldenrod" to Color(0xFFB8860B), + "darkgray" to Color(0xFFA9A9A9), + "darkgreen" to Color(0xFF006400), + "darkgrey" to Color(0xFFA9A9A9), + "darkkhaki" to Color(0xFFBDB76B), + "darkmagenta" to Color(0xFF8B008B), + "darkolivegreen" to Color(0xFF556B2F), + "darkorange" to Color(0xFFFF8C00), + "darkorchid" to Color(0xFF9932CC), + "darkred" to Color(0xFF8B0000), + "darksalmon" to Color(0xFFE9967A), + "darkseagreen" to Color(0xFF8FBC8F), + "darkslateblue" to Color(0xFF483D8B), + "darkslategray" to Color(0xFF2F4F4F), + "darkslategrey" to Color(0xFF2F4F4F), + "darkturquoise" to Color(0xFF00CED1), + "darkviolet" to Color(0xFF9400D3), + "deeppink" to Color(0xFFFF1493), + "deepskyblue" to Color(0xFF00BFFF), + "dimgray" to Color(0xFF696969), + "dimgrey" to Color(0xFF696969), + "dodgerblue" to Color(0xFF1E90FF), + "firebrick" to Color(0xFFB22222), + "floralwhite" to Color(0xFFFFFAF0), + "forestgreen" to Color(0xFF228B22), + "fuchsia" to Color(0xFFFF00FF), + "gainsboro" to Color(0xFFDCDCDC), + "ghostwhite" to Color(0xFFF8F8FF), + "gold" to Color(0xFFFFD700), + "goldenrod" to Color(0xFFDAA520), + "gray" to Color(0xFF808080), + "green" to Color(0xFF008000), + "greenyellow" to Color(0xFFADFF2F), + "grey" to Color(0xFF808080), + "honeydew" to Color(0xFFF0FFF0), + "hotpink" to Color(0xFFFF69B4), + "indianred" to Color(0xFFCD5C5C), + "indigo" to Color(0xFF4B0082), + "ivory" to Color(0xFFFFFFF0), + "khaki" to Color(0xFFF0E68C), + "lavender" to Color(0xFFE6E6FA), + "lavenderblush" to Color(0xFFFFF0F5), + "lawngreen" to Color(0xFF7CFC00), + "lemonchiffon" to Color(0xFFFFFACD), + "lightblue" to Color(0xFFADD8E6), + "lightcoral" to Color(0xFFF08080), + "lightcyan" to Color(0xFFE0FFFF), + "lightgoldenrodyellow" to Color(0xFFFAFAD2), + "lightgray" to Color(0xFFD3D3D3), + "lightgreen" to Color(0xFF90EE90), + "lightgrey" to Color(0xFFD3D3D3), + "lightpink" to Color(0xFFFFB6C1), + "lightsalmon" to Color(0xFFFFA07A), + "lightseagreen" to Color(0xFF20B2AA), + "lightskyblue" to Color(0xFF87CEFA), + "lightslategray" to Color(0xFF778899), + "lightslategrey" to Color(0xFF778899), + "lightsteelblue" to Color(0xFFB0C4DE), + "lightyellow" to Color(0xFFFFFFE0), + "lime" to Color(0xFF00FF00), + "limegreen" to Color(0xFF32CD32), + "linen" to Color(0xFFFAF0E6), + "magenta" to Color(0xFFFF00FF), + "maroon" to Color(0xFF800000), + "mediumaquamarine" to Color(0xFF66CDAA), + "mediumblue" to Color(0xFF0000CD), + "mediumorchid" to Color(0xFFBA55D3), + "mediumpurple" to Color(0xFF9370DB), + "mediumseagreen" to Color(0xFF3CB371), + "mediumslateblue" to Color(0xFF7B68EE), + "mediumspringgreen" to Color(0xFF00FA9A), + "mediumturquoise" to Color(0xFF48D1CC), + "mediumvioletred" to Color(0xFFC71585), + "midnightblue" to Color(0xFF191970), + "mintcream" to Color(0xFFF5FFFA), + "mistyrose" to Color(0xFFFFE4E1), + "moccasin" to Color(0xFFFFE4B5), + "navajowhite" to Color(0xFFFFDEAD), + "navy" to Color(0xFF000080), + "oldlace" to Color(0xFFFDF5E6), + "olive" to Color(0xFF808000), + "olivedrab" to Color(0xFF6B8E23), + "orange" to Color(0xFFFFA500), + "orangered" to Color(0xFFFF4500), + "orchid" to Color(0xFFDA70D6), + "palegoldenrod" to Color(0xFFEEE8AA), + "palegreen" to Color(0xFF98FB98), + "paleturquoise" to Color(0xFFAFEEEE), + "palevioletred" to Color(0xFFDB7093), + "papayawhip" to Color(0xFFFFEFD5), + "peachpuff" to Color(0xFFFFDAB9), + "peru" to Color(0xFFCD853F), + "pink" to Color(0xFFFFC0CB), + "plum" to Color(0xFFDDA0DD), + "powderblue" to Color(0xFFB0E0E6), + "purple" to Color(0xFF800080), + "red" to Color(0xFFFF0000), + "rosybrown" to Color(0xFFBC8F8F), + "royalblue" to Color(0xFF4169E1), + "saddlebrown" to Color(0xFF8B4513), + "salmon" to Color(0xFFFA8072), + "sandybrown" to Color(0xFFF4A460), + "seagreen" to Color(0xFF2E8B57), + "seashell" to Color(0xFFFFF5EE), + "sienna" to Color(0xFFA0522D), + "silver" to Color(0xFFC0C0C0), + "skyblue" to Color(0xFF87CEEB), + "slateblue" to Color(0xFF6A5ACD), + "slategray" to Color(0xFF708090), + "slategrey" to Color(0xFF708090), + "snow" to Color(0xFFFFFAFA), + "springgreen" to Color(0xFF00FF7F), + "steelblue" to Color(0xFF4682B4), + "tan" to Color(0xFFD2B48C), + "teal" to Color(0xFF008080), + "thistle" to Color(0xFFD8BFD8), + "tomato" to Color(0xFFFF6347), + "turquoise" to Color(0xFF40E0D0), + "violet" to Color(0xFFEE82EE), + "wheat" to Color(0xFFF5DEB3), + "white" to Color(0xFFFFFFFF), + "whitesmoke" to Color(0xFFF5F5F5), + "yellow" to Color(0xFFFFFF00), + "yellowgreen" to Color(0xFF9ACD32) + ) + + /** + * Parses a CSS color string. + * + * Delegates to [CssColorParser], a port of MapLibre's CSS Color 4 parser, so the space-separated + * grammar (`rgb(255 0 0 / 60%)`), percentage components and 4/8-digit hex all work. The legacy + * hand-rolled branches below are kept only as a fallback for inputs that parser rejects. + */ + fun parseColorString(color: String): Color { + CssColorParser.parse(color)?.let { (r, g, b, a) -> + return Color(r.toFloat(), g.toFloat(), b.toFloat(), a.toFloat()) + } + return when { + color.startsWith("#") -> parseHex(color) + color.startsWith("rgb(") -> parseRgb(color) + color.startsWith("rgba(") -> parseRgba(color) + color.startsWith("hsl(") -> parseHsl(color) + color.startsWith("hsla(") -> parseHsla(color) + else -> namedColors[color.lowercase()] ?: parseError("unsupported color name $color") + } + } + + fun parseColorStringOrNull(color: String): Color? { + return try { + parseColorString(color) + } catch (e: Throwable) { + null + } + } + + fun colorToHexString(color: Color): String { + val argb = color.toArgb() + val r = (argb shr 16) and 0xFF + val g = (argb shr 8) and 0xFF + val b = argb and 0xFF + val a = (argb shr 24) and 0xFF + + fun toHex(value: Int) = value.toString(16).padStart(2, '0') + + return buildString { + append("#") + append(toHex(r)) + append(toHex(g)) + append(toHex(b)) + if (a != 0xFF) { + append(toHex(a)) + } + }.uppercase() + } + + private fun parseHex(hex: String): Color { + return when (hex.length) { + 4 -> { // #RGB + val r = hex.substring(1, 2).repeat(2).toInt(16) + val g = hex.substring(2, 3).repeat(2).toInt(16) + val b = hex.substring(3, 4).repeat(2).toInt(16) + Color(r, g, b) + } + + 7 -> { // #RRGGBB + val r = hex.substring(1, 3).toInt(16) + val g = hex.substring(3, 5).toInt(16) + val b = hex.substring(5, 7).toInt(16) + Color(r, g, b) + } + + 9 -> { // #RRGGBBAA + val r = hex.substring(1, 3).toInt(16) + val g = hex.substring(3, 5).toInt(16) + val b = hex.substring(5, 7).toInt(16) + val a = hex.substring(7, 9).toInt(16) + Color(r, g, b, a) + } + + else -> parseError("Invalid hex $hex") + } + } + + private fun parseRgb(rgb: String): Color { + val values = rgb.substring(4, rgb.length - 1) + .split(",") + .map { it.trim() } + .mapNotNull { it.toIntOrNull() } + return if (values.size == 3) { + Color(values[0], values[1], values[2]) + } else parseError("Invalid rgb $rgb") + } + + private fun parseRgba(rgba: String): Color { + val values = rgba.substring(5, rgba.length - 1) + .split(",") + .map { it.trim() } + return if (values.size == 4) { + val r = values[0].toIntOrNull() ?: parseError("Invalid rgba $rgba") + val g = values[1].toIntOrNull() ?: parseError("Invalid rgba $rgba") + val b = values[2].toIntOrNull() ?: parseError("Invalid rgba $rgba") + val alpha = values[3].toFloatOrNull() ?: parseError("Invalid rgba $rgba") + Color(r, g, b, (alpha * 255f).roundToInt()) + } else parseError("Invalid rgba $rgba") + } + + private fun parseHsl(hsl: String): Color { + val values = hsl.substring(4, hsl.length - 1) + .split(",") + .map { it.trim() } + return if (values.size == 3) { + val h = values[0].toFloatOrNull() ?: parseError("Invalid hsl $hsl") + val s = values[1].removeSuffix("%").toFloatOrNull() ?: parseError("Invalid hsl $hsl") + val l = values[2].removeSuffix("%").toFloatOrNull() ?: parseError("Invalid hsl $hsl") + Color.hsl(hue = h, saturation = s / 100f, lightness = l / 100f) + } else parseError("Invalid hsl $hsl") + } + + private fun parseHsla(hsla: String): Color { + val values = hsla.substring(5, hsla.length - 1) + .split(",") + .map { it.trim() } + return if (values.size == 4) { + val h = values[0].toFloatOrNull() ?: parseError("Invalid hsl $hsla") + val s = values[1].removeSuffix("%").toFloatOrNull() ?: parseError("Invalid hsl $hsla") + val l = values[2].removeSuffix("%").toFloatOrNull() ?: parseError("Invalid hsl $hsla") + val alpha = values[3].toFloatOrNull() ?: parseError("Invalid hsl $hsla") + Color.hsl(hue = h, saturation = s / 100f, lightness = l / 100f, alpha = alpha) + } else parseError("Invalid hsl $hsla") + } + + private fun parseError(msg: String): Nothing { + throw ColorParserException(msg) + } + + class ColorParserException(message: String) : Throwable(message = message) +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/StyleDiagnostics.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/StyleDiagnostics.kt new file mode 100644 index 00000000..cead6c16 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/StyleDiagnostics.kt @@ -0,0 +1,44 @@ +package ovh.plrapps.mapcompose.vector.spec.style.utils + +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExpressionParsingError + +/** A problem found while parsing a style, recorded instead of thrown. */ +data class StyleDiagnostic( + /** Where the problem is: the property's JSON text, or the layer id for a filter. */ + val location: String, + val key: String, + val message: String, +) { + override fun toString(): String = "$location$key: $message" +} + +/** + * Collects expression and filter parse errors during style deserialization. + * + * kotlinx-serialization gives a custom serializer no channel to report non-fatal problems, so + * diagnostics are accumulated here and drained by `getMapLibreConfiguration` once the style has + * been decoded. MapLibre's equivalent is the `Array` that `createExpression` + * returns to the style loader. + * + * Style parsing is single-threaded (one `Json.decodeFromString` call), so a plain list is enough. + */ +object StyleDiagnostics { + private val entries = mutableListOf() + + fun report(location: String, errors: List) { + for (error in errors) { + entries.add(StyleDiagnostic(location = location, key = error.key, message = error.message)) + } + } + + fun report(location: String, message: String) { + entries.add(StyleDiagnostic(location = location, key = "", message = message)) + } + + /** Returns everything collected since the last call and clears the buffer. */ + fun drain(): List { + val result = entries.toList() + entries.clear() + return result + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/tilejson/TileJson.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/tilejson/TileJson.kt new file mode 100644 index 00000000..78b9f3e3 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/tilejson/TileJson.kt @@ -0,0 +1,35 @@ +package ovh.plrapps.mapcompose.vector.spec.tilejson + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TileJson( + val tilejson: String, + val name: String? = null, + val description: String? = null, + val version: String? = null, + val attribution: String? = null, + val template: String? = null, + val legend: String? = null, + val scheme: String = "xyz", + val tiles: List, + val grids: List? = null, + val data: List? = null, + val minzoom: Int = 0, + val maxzoom: Int = 22, + val bounds: List? = null, // [west, south, east, north] + val center: List? = null, // [lon, lat, zoom] + + @SerialName("vector_layers") + val vectorLayers: List? = null +) + +@Serializable +data class VectorLayer( + val id: String, + val description: String? = null, + val fields: Map = emptyMap(), + val minzoom: Int? = null, + val maxzoom: Int? = null +) diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/vector_tile.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/vector_tile.kt new file mode 100644 index 00000000..8bf4ff85 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/vector_tile.kt @@ -0,0 +1,445 @@ +@file:OptIn(pbandk.PublicForGeneratedCode::class) + +package ovh.plrapps.mapcompose.vector.spec + +@pbandk.Export +data class Tile( + val layers: List = emptyList(), + override val unknownFields: Map = emptyMap(), + @property:pbandk.PbandkInternal + override val extensionFields: pbandk.ExtensionFieldSet = pbandk.ExtensionFieldSet() +) : pbandk.ExtendableMessage { + override operator fun plus(other: pbandk.Message?): Tile = protoMergeImpl(other) + override val descriptor: pbandk.MessageDescriptor get() = Companion.descriptor + override val protoSize: Int by lazy { super.protoSize } + companion object : pbandk.Message.Companion { + val defaultInstance: Tile by lazy { Tile() } + override fun decodeWith(u: pbandk.MessageDecoder): Tile = Tile.decodeWithImpl(u) + + override val descriptor: pbandk.MessageDescriptor = pbandk.MessageDescriptor( + fullName = "vector_tile.Tile", + messageClass = Tile::class, + messageCompanion = this, + fields = buildList(1) { + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "layers", + number = 3, + type = pbandk.FieldDescriptor.Type.Repeated(valueType = pbandk.FieldDescriptor.Type.Message(messageCompanion = Layer.Companion)), + jsonName = "layers", + value = Tile::layers + ) + ) + } + ) + } + + sealed class GeomType(override val value: Int, override val name: String? = null) : pbandk.Message.Enum { + override fun equals(other: Any?): Boolean = other is GeomType && other.value == value + override fun hashCode(): Int = value.hashCode() + override fun toString(): String = "Tile.GeomType.${name ?: "UNRECOGNIZED"}(value=$value)" + + object UNKNOWN : GeomType(0, "UNKNOWN") + object POINT : GeomType(1, "POINT") + object LINESTRING : GeomType(2, "LINESTRING") + object POLYGON : GeomType(3, "POLYGON") + class UNRECOGNIZED(value: Int) : GeomType(value) + + companion object : pbandk.Message.Enum.Companion { + val values: List by lazy { listOf(UNKNOWN, POINT, LINESTRING, POLYGON) } + override fun fromValue(value: Int): GeomType = values.firstOrNull { it.value == value } ?: UNRECOGNIZED(value) + override fun fromName(name: String): GeomType = values.firstOrNull { it.name == name } ?: throw IllegalArgumentException("No GeomType with name: $name") + } + } + + data class Value( + val stringValue: String? = null, + val floatValue: Float? = null, + val doubleValue: Double? = null, + val intValue: Long? = null, + val uintValue: Long? = null, + val sintValue: Long? = null, + val boolValue: Boolean? = null, + override val unknownFields: Map = emptyMap(), + @property:pbandk.PbandkInternal + override val extensionFields: pbandk.ExtensionFieldSet = pbandk.ExtensionFieldSet() + ) : pbandk.ExtendableMessage { + override operator fun plus(other: pbandk.Message?): Value = protoMergeImpl(other) + override val descriptor: pbandk.MessageDescriptor get() = Companion.descriptor + override val protoSize: Int by lazy { super.protoSize } + companion object : pbandk.Message.Companion { + val defaultInstance: Value by lazy { Value() } + override fun decodeWith(u: pbandk.MessageDecoder): Value = Value.decodeWithImpl(u) + + override val descriptor: pbandk.MessageDescriptor = pbandk.MessageDescriptor( + fullName = "vector_tile.Tile.Value", + messageClass = Value::class, + messageCompanion = this, + fields = buildList(7) { + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "string_value", + number = 1, + type = pbandk.FieldDescriptor.Type.Primitive.String(hasPresence = true), + jsonName = "stringValue", + value = Value::stringValue + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "float_value", + number = 2, + type = pbandk.FieldDescriptor.Type.Primitive.Float(hasPresence = true), + jsonName = "floatValue", + value = Value::floatValue + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "double_value", + number = 3, + type = pbandk.FieldDescriptor.Type.Primitive.Double(hasPresence = true), + jsonName = "doubleValue", + value = Value::doubleValue + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "int_value", + number = 4, + type = pbandk.FieldDescriptor.Type.Primitive.Int64(hasPresence = true), + jsonName = "intValue", + value = Value::intValue + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "uint_value", + number = 5, + type = pbandk.FieldDescriptor.Type.Primitive.UInt64(hasPresence = true), + jsonName = "uintValue", + value = Value::uintValue + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "sint_value", + number = 6, + type = pbandk.FieldDescriptor.Type.Primitive.SInt64(hasPresence = true), + jsonName = "sintValue", + value = Value::sintValue + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "bool_value", + number = 7, + type = pbandk.FieldDescriptor.Type.Primitive.Bool(hasPresence = true), + jsonName = "boolValue", + value = Value::boolValue + ) + ) + } + ) + } + } + + data class Feature( + val id: Long? = null, + val tags: List = emptyList(), + val type: GeomType? = null, + val geometry: List = emptyList(), + override val unknownFields: Map = emptyMap() + ) : pbandk.Message { + override operator fun plus(other: pbandk.Message?): Feature = protoMergeImpl(other) + override val descriptor: pbandk.MessageDescriptor get() = Companion.descriptor + override val protoSize: Int by lazy { super.protoSize } + companion object : pbandk.Message.Companion { + val defaultInstance: Feature by lazy { Feature() } + override fun decodeWith(u: pbandk.MessageDecoder): Feature = Feature.decodeWithImpl(u) + + override val descriptor: pbandk.MessageDescriptor = pbandk.MessageDescriptor( + fullName = "vector_tile.Tile.Feature", + messageClass = Feature::class, + messageCompanion = this, + fields = buildList(4) { + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "id", + number = 1, + type = pbandk.FieldDescriptor.Type.Primitive.UInt64(hasPresence = true), + jsonName = "id", + value = Feature::id + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "tags", + number = 2, + type = pbandk.FieldDescriptor.Type.Repeated(valueType = pbandk.FieldDescriptor.Type.Primitive.UInt32(), packed = true), + jsonName = "tags", + value = Feature::tags + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "type", + number = 3, + type = pbandk.FieldDescriptor.Type.Enum(enumCompanion = GeomType.Companion, hasPresence = true), + jsonName = "type", + value = Feature::type + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "geometry", + number = 4, + type = pbandk.FieldDescriptor.Type.Repeated(valueType = pbandk.FieldDescriptor.Type.Primitive.UInt32(), packed = true), + jsonName = "geometry", + value = Feature::geometry + ) + ) + } + ) + } + } + + data class Layer( + val version: Int, + val name: String, + val features: List = emptyList(), + val keys: List = emptyList(), + val values: List = emptyList(), + val extent: Int? = null, + override val unknownFields: Map = emptyMap(), + @property:pbandk.PbandkInternal + override val extensionFields: pbandk.ExtensionFieldSet = pbandk.ExtensionFieldSet() + ) : pbandk.ExtendableMessage { + override operator fun plus(other: pbandk.Message?): Layer = protoMergeImpl(other) + override val descriptor: pbandk.MessageDescriptor get() = Companion.descriptor + override val protoSize: Int by lazy { super.protoSize } + companion object : pbandk.Message.Companion { + override fun decodeWith(u: pbandk.MessageDecoder): Layer = Layer.decodeWithImpl(u) + + override val descriptor: pbandk.MessageDescriptor = pbandk.MessageDescriptor( + fullName = "vector_tile.Tile.Layer", + messageClass = Layer::class, + messageCompanion = this, + fields = buildList(6) { + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "name", + number = 1, + type = pbandk.FieldDescriptor.Type.Primitive.String(hasPresence = true), + jsonName = "name", + value = Layer::name + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "features", + number = 2, + type = pbandk.FieldDescriptor.Type.Repeated(valueType = pbandk.FieldDescriptor.Type.Message(messageCompanion = Feature.Companion)), + jsonName = "features", + value = Layer::features + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "keys", + number = 3, + type = pbandk.FieldDescriptor.Type.Repeated(valueType = pbandk.FieldDescriptor.Type.Primitive.String()), + jsonName = "keys", + value = Layer::keys + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "values", + number = 4, + type = pbandk.FieldDescriptor.Type.Repeated(valueType = pbandk.FieldDescriptor.Type.Message(messageCompanion = Value.Companion)), + jsonName = "values", + value = Layer::values + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "extent", + number = 5, + type = pbandk.FieldDescriptor.Type.Primitive.UInt32(hasPresence = true), + jsonName = "extent", + value = Layer::extent + ) + ) + add( + pbandk.FieldDescriptor( + messageDescriptor = this@Companion::descriptor, + name = "version", + number = 15, + type = pbandk.FieldDescriptor.Type.Primitive.UInt32(hasPresence = true), + jsonName = "version", + value = Layer::version + ) + ) + } + ) + } + } +} + +@pbandk.Export +@pbandk.JsName("orDefaultForTile") +fun Tile?.orDefault(): Tile = this ?: Tile.defaultInstance + +private fun Tile.protoMergeImpl(plus: pbandk.Message?): Tile = (plus as? Tile)?.let { + it.copy( + layers = layers + plus.layers, + unknownFields = unknownFields + plus.unknownFields + ) +} ?: this + +@Suppress("UNCHECKED_CAST") +private fun Tile.Companion.decodeWithImpl(u: pbandk.MessageDecoder): Tile { + var layers: pbandk.ListWithSize.Builder? = null + + val unknownFields = u.readMessage(this) { _fieldNumber, _fieldValue -> + when (_fieldNumber) { + 3 -> layers = (layers ?: pbandk.ListWithSize.Builder()).apply { this += _fieldValue as Sequence } + } + } + + return Tile(pbandk.ListWithSize.Builder.fixed(layers), unknownFields) +} + +@pbandk.Export +@pbandk.JsName("orDefaultForTileValue") +fun Tile.Value?.orDefault(): Tile.Value = this ?: Tile.Value.defaultInstance + +private fun Tile.Value.protoMergeImpl(plus: pbandk.Message?): Tile.Value = (plus as? Tile.Value)?.let { + it.copy( + stringValue = plus.stringValue ?: stringValue, + floatValue = plus.floatValue ?: floatValue, + doubleValue = plus.doubleValue ?: doubleValue, + intValue = plus.intValue ?: intValue, + uintValue = plus.uintValue ?: uintValue, + sintValue = plus.sintValue ?: sintValue, + boolValue = plus.boolValue ?: boolValue, + unknownFields = unknownFields + plus.unknownFields + ) +} ?: this + +@Suppress("UNCHECKED_CAST") +private fun Tile.Value.Companion.decodeWithImpl(u: pbandk.MessageDecoder): Tile.Value { + var stringValue: String? = null + var floatValue: Float? = null + var doubleValue: Double? = null + var intValue: Long? = null + var uintValue: Long? = null + var sintValue: Long? = null + var boolValue: Boolean? = null + + val unknownFields = u.readMessage(this) { _fieldNumber, _fieldValue -> + when (_fieldNumber) { + 1 -> stringValue = _fieldValue as String + 2 -> floatValue = _fieldValue as Float + 3 -> doubleValue = _fieldValue as Double + 4 -> intValue = _fieldValue as Long + 5 -> uintValue = _fieldValue as Long + 6 -> sintValue = _fieldValue as Long + 7 -> boolValue = _fieldValue as Boolean + } + } + + return Tile.Value(stringValue, floatValue, doubleValue, intValue, + uintValue, sintValue, boolValue, unknownFields) +} + +@pbandk.Export +@pbandk.JsName("orDefaultForTileFeature") +fun Tile.Feature?.orDefault(): Tile.Feature = this ?: Tile.Feature.defaultInstance + +private fun Tile.Feature.protoMergeImpl(plus: pbandk.Message?): Tile.Feature = (plus as? Tile.Feature)?.let { + it.copy( + id = plus.id ?: id, + tags = tags + plus.tags, + type = plus.type ?: type, + geometry = geometry + plus.geometry, + unknownFields = unknownFields + plus.unknownFields + ) +} ?: this + +@Suppress("UNCHECKED_CAST") +private fun Tile.Feature.Companion.decodeWithImpl(u: pbandk.MessageDecoder): Tile.Feature { + var id: Long? = null + var tags: pbandk.ListWithSize.Builder? = null + var type: Tile.GeomType? = null + var geometry: pbandk.ListWithSize.Builder? = null + + val unknownFields = u.readMessage(this) { _fieldNumber, _fieldValue -> + when (_fieldNumber) { + 1 -> id = _fieldValue as Long + 2 -> tags = (tags ?: pbandk.ListWithSize.Builder()).apply { this += _fieldValue as Sequence } + 3 -> type = _fieldValue as Tile.GeomType + 4 -> geometry = (geometry ?: pbandk.ListWithSize.Builder()).apply { this += _fieldValue as Sequence } + } + } + + return Tile.Feature(id, pbandk.ListWithSize.Builder.fixed(tags), type, pbandk.ListWithSize.Builder.fixed(geometry), unknownFields) +} + +private fun Tile.Layer.protoMergeImpl(plus: pbandk.Message?): Tile.Layer = (plus as? Tile.Layer)?.let { + it.copy( + features = features + plus.features, + keys = keys + plus.keys, + values = values + plus.values, + extent = plus.extent ?: extent, + unknownFields = unknownFields + plus.unknownFields + ) +} ?: this + +@Suppress("UNCHECKED_CAST") +private fun Tile.Layer.Companion.decodeWithImpl(u: pbandk.MessageDecoder): Tile.Layer { + var version: Int? = null + var name: String? = null + var features: pbandk.ListWithSize.Builder? = null + var keys: pbandk.ListWithSize.Builder? = null + var values: pbandk.ListWithSize.Builder? = null + var extent: Int? = null + + val unknownFields = u.readMessage(this) { _fieldNumber, _fieldValue -> + when (_fieldNumber) { + 1 -> name = _fieldValue as String + 2 -> features = (features ?: pbandk.ListWithSize.Builder()).apply { this += _fieldValue as Sequence } + 3 -> keys = (keys ?: pbandk.ListWithSize.Builder()).apply { this += _fieldValue as Sequence } + 4 -> values = (values ?: pbandk.ListWithSize.Builder()).apply { this += _fieldValue as Sequence } + 5 -> extent = _fieldValue as Int + 15 -> version = _fieldValue as Int + } + } + + if (version == null) { + throw pbandk.InvalidProtocolBufferException.missingRequiredField("version") + } + if (name == null) { + throw pbandk.InvalidProtocolBufferException.missingRequiredField("name") + } + return Tile.Layer(version, name, pbandk.ListWithSize.Builder.fixed(features), pbandk.ListWithSize.Builder.fixed(keys), + pbandk.ListWithSize.Builder.fixed(values), extent, unknownFields) +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/ui/state/SymbolState.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/ui/state/SymbolState.kt new file mode 100644 index 00000000..f1e78219 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/ui/state/SymbolState.kt @@ -0,0 +1,11 @@ +package ovh.plrapps.mapcompose.vector.ui.state + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import ovh.plrapps.mapcompose.vector.renderer.Symbol + +internal class SymbolState { + var symbols by mutableStateOf>(emptyList()) + var visiblePhases by mutableStateOf(0..0) +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/ui/symbols/SymbolComposer.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/ui/symbols/SymbolComposer.kt new file mode 100644 index 00000000..6e06191f --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/ui/symbols/SymbolComposer.kt @@ -0,0 +1,74 @@ +package ovh.plrapps.mapcompose.vector.ui.symbols + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.drawscope.withTransform +import ovh.plrapps.mapcompose.ui.layout.grid +import ovh.plrapps.mapcompose.vector.ui.state.SymbolState +import ovh.plrapps.mapcompose.ui.state.ZoomPanRotateState +import ovh.plrapps.mapcompose.vector.renderer.Symbol +import kotlin.math.ceil + +@Composable +internal fun SymbolComposer( + modifier: Modifier, + zoomPRState: ZoomPanRotateState, + symbolState: SymbolState +) { + Canvas( + modifier = modifier.fillMaxSize() + ) { + val x0 = ((ceil(zoomPRState.scrollX / grid) * grid)).toInt() + val y0 = ((ceil(zoomPRState.scrollY / grid) * grid)).toInt() + + withTransform({ + rotate( + degrees = zoomPRState.rotation, + pivot = Offset( + x = zoomPRState.pivotX.toFloat(), + y = zoomPRState.pivotY.toFloat() + ) + ) + translate( + left = (-zoomPRState.scrollX + x0).toFloat(), + top = (-zoomPRState.scrollY + y0).toFloat() + ) + }) { + for (phase in symbolState.visiblePhases) { + val phaseOffsetPx = phase * zoomPRState.fullWidth * zoomPRState.scale + for (symbol in symbolState.symbols.reversed()) { + val canvasX: Float + val canvasY: Float + if (symbol is Symbol.Text && symbol.spriteAnchorGlobal != null && symbol.textOffset != null) { + canvasX = (symbol.spriteAnchorGlobal.x * zoomPRState.fullWidth * zoomPRState.scale - x0 + + symbol.textOffset.first + phaseOffsetPx).toFloat() + canvasY = (symbol.spriteAnchorGlobal.y * zoomPRState.fullHeight * zoomPRState.scale - y0 + + symbol.textOffset.second).toFloat() + } else { + canvasX = (symbol.global.x * zoomPRState.fullWidth * zoomPRState.scale - x0 + phaseOffsetPx).toFloat() + canvasY = (symbol.global.y * zoomPRState.fullHeight * zoomPRState.scale - y0).toFloat() + } + + val size = symbol.getInPixels() + val offsetX = size.width * symbol.align.x + val offsetY = size.height * symbol.align.y + + withTransform({ + translate(left = canvasX + offsetX, top = canvasY + offsetY) + if (symbol.viewportAligned) { + // Counter-rotate around the symbol's geographic reference point in local + // coords (= -offsetX, -offsetY), so the symbol stays horizontal on screen + // while its position still tracks the map-rotated viewport coordinate. + rotate(degrees = -zoomPRState.rotation, pivot = Offset(-offsetX, -offsetY)) + } + }) { + symbol.draw(this) + } + } + } + } + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/LruCache.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/LruCache.kt new file mode 100644 index 00000000..b2ace607 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/LruCache.kt @@ -0,0 +1,30 @@ +package ovh.plrapps.mapcompose.vector.utils + +class LruCache(private val maxSize: Int) { + private val cache = LinkedHashMap(0, 0.75f) + + fun get(key: K): V? { + val value = cache.remove(key) + if (value != null) { + cache[key] = value + } + return value + } + + fun put(key: K, value: V) { + cache.remove(key) + if (cache.size >= maxSize) { + val oldestKey = cache.keys.firstOrNull() + if (oldestKey != null) { + cache.remove(oldestKey) + } + } + cache[key] = value + } + + fun remove(key: K): V? { return cache.remove(key) } + + fun clear() { cache.clear() } + + fun size(): Int = cache.size +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/compose/RTreeCompose.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/compose/RTreeCompose.kt new file mode 100644 index 00000000..da908a18 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/compose/RTreeCompose.kt @@ -0,0 +1,34 @@ +package ovh.plrapps.mapcompose.vector.utils.compose + +import androidx.compose.ui.geometry.Rect +import ovh.plrapps.mapcompose.vector.utils.rtree.AABB +import ovh.plrapps.mapcompose.vector.utils.rtree.Rtree + +/** + * Converts Compose Rect to AABB + */ +fun Rect.toAABB(): AABB { + return AABB( + minX = left, + minY = top, + maxX = right, + maxY = bottom + ) +} + +/** + * Inserts a Compose Rect into R-tree with custom data + * @param id unique identifier for the item + * @param data custom data to store with the item + */ +fun Rtree.insert(id: String, rect: Rect, data: T) { + insert(rect.toAABB(), data) +} + +/** + * Searches for items in R-tree that intersect with the given Compose Rect + * @return list of found items with their data + */ +fun Rtree.search(rect: Rect): Set { + return search(rect.toAABB()) +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/compose/toOBB.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/compose/toOBB.kt new file mode 100644 index 00000000..5e12088b --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/compose/toOBB.kt @@ -0,0 +1,54 @@ +package ovh.plrapps.mapcompose.vector.utils.compose + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import ovh.plrapps.mapcompose.vector.utils.obb.OBB +import ovh.plrapps.mapcompose.vector.utils.obb.ObbPoint +import ovh.plrapps.mapcompose.vector.utils.obb.Size as ObbSize + +/** + * Converts Compose Rect to OBB with specified rotation + * @param rotation rotation angle in degrees + */ +fun Rect.toOBB(rotation: Float = 0f): OBB { + return OBB( + center = ObbPoint( + x = (left + right) / 2, + y = (top + bottom) / 2 + ), + size = ovh.plrapps.mapcompose.vector.utils.obb.Size( + width = right - left, + height = bottom - top + ), + rotation = rotation + ) +} + +/** + * Converts Compose Offset to OBB Point + */ +fun Offset.toObbPoint(): ObbPoint { + return ObbPoint(x = x, y = y) +} + +/** + * Converts Compose Size to OBB Size + */ +fun Size.toObbSize(): ObbSize { + return ObbSize(width = width, height = height) +} + +/** + * Converts OBB Point to Compose Offset + */ +fun ObbPoint.toOffset(): Offset { + return Offset(x = x, y = y) +} + +/** + * Converts OBB Size to Compose Size + */ +fun ObbSize.toSize(): Size { + return Size(width = width, height = height) +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/Obb.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/Obb.kt new file mode 100644 index 00000000..cdd2b480 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/Obb.kt @@ -0,0 +1,98 @@ +package ovh.plrapps.mapcompose.vector.utils.obb + +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.PI +import ovh.plrapps.mapcompose.vector.utils.rtree.AABB + +class OBB( + val center: ObbPoint, + val size: Size, + val rotation: Float // in degrees +) { + // Convert rotation from degrees to radians + private val rotationRad = rotation * PI / 180.0 + + // Calculate rotation matrix components + private val cos = cos(rotationRad).toFloat() + private val sin = sin(rotationRad).toFloat() + + // Get the four corners of the OBB + fun getCorners(): List { + val halfWidth = size.width / 2 + val halfHeight = size.height / 2 + + // Calculate the corners before rotation + val corners = listOf( + ObbPoint(-halfWidth, -halfHeight), + ObbPoint(halfWidth, -halfHeight), + ObbPoint(halfWidth, halfHeight), + ObbPoint(-halfWidth, halfHeight) + ) + + // Rotate and translate each corner + return corners.map { corner -> + ObbPoint( + x = center.x + corner.x * cos - corner.y * sin, + y = center.y + corner.x * sin + corner.y * cos + ) + } + } + + // Get the axes of the OBB (normalized) + fun getAxes(): List { + return listOf( + ObbPoint(cos, sin), // First axis + ObbPoint(-sin, cos) // Second axis (perpendicular to first) + ) + } + + // Project a point onto an axis + private fun projectPoint(obbPoint: ObbPoint, axis: ObbPoint): Float { + return obbPoint.x * axis.x + obbPoint.y * axis.y + } + + // Project all corners onto an axis + private fun projectOBB(axis: ObbPoint): Pair { + val corners = getCorners() + val projections = corners.map { projectPoint(it, axis) } + return Pair( + projections.minOrNull() ?: 0f, + projections.maxOrNull() ?: 0f + ) + } + + // Check if two OBBs intersect using Separating Axis Theorem + fun intersects(other: OBB): Boolean { + // Get all axes to check + val axes = getAxes() + other.getAxes() + + // Project both OBBs onto each axis + for (axis in axes) { + val (min1, max1) = projectOBB(axis) + val (min2, max2) = other.projectOBB(axis) + + // If there is a gap, the OBBs do not intersect + if (max1 < min2 || max2 < min1) { + return false + } + } + + // If no gap was found on any axis, the OBBs intersect + return true + } + + // Get the AABB that contains this OBB + fun getAABB(): AABB { + val corners = getCorners() + val xs = corners.map { it.x } + val ys = corners.map { it.y } + + return AABB( + minX = xs.minOrNull() ?: 0f, + minY = ys.minOrNull() ?: 0f, + maxX = xs.maxOrNull() ?: 0f, + maxY = ys.maxOrNull() ?: 0f + ) + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/ObbPoint.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/ObbPoint.kt new file mode 100644 index 00000000..261b3794 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/ObbPoint.kt @@ -0,0 +1,3 @@ +package ovh.plrapps.mapcompose.vector.utils.obb + +data class ObbPoint(val x: Float, val y: Float) \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/README.md b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/README.md new file mode 100644 index 00000000..1c5f681c --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/README.md @@ -0,0 +1,66 @@ +# OBB (Oriented Bounding Box) Implementation + +## Description +Implementation of Oriented Bounding Box for precise intersection detection. OBB is a rectangle that can be rotated to any angle, providing a tighter fit around objects compared to AABB. + +## Key Features +- Support for rotated rectangles +- Precise intersection detection +- Efficient collision checking +- Support for 2D space +- Support for any rotation angle +- Support for any size + +## Usage +```kotlin +// Create OBB with center point, size and rotation +val obb = OBB( + center = Point(10f, 10f), + size = Size(20f, 10f), + rotation = 45f // degrees +) + +// Check intersection with another OBB +val otherObb = OBB( + center = Point(15f, 15f), + size = Size(10f, 10f), + rotation = 0f +) + +val isIntersecting = obb.intersects(otherObb) + +// Get corners of OBB +val corners = obb.getCorners() + +// Get AABB that contains this OBB +val aabb = obb.getAABB() +``` + +## Algorithm +OBB intersection test uses the Separating Axis Theorem (SAT): +1. Project both OBBs onto each other's axes +2. If there is a gap in any projection, the OBBs do not intersect +3. If there is no gap in any projection, the OBBs intersect + +## Performance +- O(n) complexity for intersection test, where n is the number of axes to check +- For 2D OBB, we need to check 4 axes (2 from each OBB) +- Much more precise than AABB, but slower +- Best used after AABB/R-tree filtering + +## Integration with R-tree +1. R-tree with AABB finds potential intersections +2. OBB performs precise intersection check only for candidates +3. This two-step approach provides both speed and accuracy + +## Notes +- OBB is more computationally expensive than AABB +- Should be used only for precise collision detection +- Best used in combination with R-tree for optimal performance +- Rotation is specified in degrees for simplicity +- All coordinates and sizes are in float for precision + +## Running Tests +```bash +./gradlew :composeApp:cleanJvmTest :composeApp:jvmTest --tests "ovh.plrapps.mapcompose.maplibre.utils.obb.ObbTest" +``` diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/Size.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/Size.kt new file mode 100644 index 00000000..5462d417 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/Size.kt @@ -0,0 +1,3 @@ +package ovh.plrapps.mapcompose.vector.utils.obb + +data class Size(val width: Float, val height: Float) \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/rtree/AABB.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/rtree/AABB.kt new file mode 100644 index 00000000..bc3cc961 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/rtree/AABB.kt @@ -0,0 +1,58 @@ +package ovh.plrapps.mapcompose.vector.utils.rtree + +data class AABB( + val minX: Float, + val minY: Float, + val maxX: Float, + val maxY: Float, +) { + init { + require(maxX >= minX && maxY >= minY) { "Invalid AABB: max coordinates must be greater than or equal to min coordinates" } + } + + val isPoint: Boolean = minX == maxX && minY == maxY + + private val cachedArea: Float = (maxX - minX) * (maxY - minY) + + private var cachedUnion: AABB? = null + private var cachedUnionWith: AABB? = null + + fun intersects(other: AABB): Boolean { + val intersectsX = maxX >= other.minX && minX <= other.maxX + val intersectsY = maxY >= other.minY && minY <= other.maxY + + return intersectsX && intersectsY + } + + fun contains(other: AABB): Boolean { + return minX <= other.minX && + maxX >= other.maxX && + minY <= other.minY && + maxY >= other.maxY + } + + fun union(other: AABB): AABB { + if (cachedUnionWith == other) { + return cachedUnion ?: run { + val result = AABB( + minOf(minX, other.minX), + minOf(minY, other.minY), + maxOf(maxX, other.maxX), + maxOf(maxY, other.maxY) + ) + cachedUnion = result + result + } + } + cachedUnionWith = other + cachedUnion = AABB( + minOf(minX, other.minX), + minOf(minY, other.minY), + maxOf(maxX, other.maxX), + maxOf(maxY, other.maxY) + ) + return cachedUnion!! + } + + fun area(): Float = cachedArea +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/rtree/README.md b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/rtree/README.md new file mode 100644 index 00000000..e4ed95ed --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/rtree/README.md @@ -0,0 +1,86 @@ +# R-tree Implementation + +## Description +Implementation of a classical R-tree for efficient AABB (Axis-Aligned Bounding Box) intersection queries. +In this implementation, boundary touching is considered as intersection, which aligns with classical R-tree behavior. + +## Key Features +- AABB (Axis-Aligned Bounding Box) support +- Boundary touching is considered as intersection +- Efficient intersection queries +- Support for points (zero-sized AABBs) +- Support for negative coordinates +- Support for large and small numbers +- Multi-level structure with automatic node splitting +- Configurable node size (maxEntries and minEntries) + +## Performance Characteristics +- Average search speedup: 6.2x compared to naive search +- Optimal for small to medium search areas (5-7x speedup) +- Efficient for large datasets (50,000+ elements) +- Tree depth scales logarithmically with dataset size +- Memory efficient due to configurable node size + +## Tests +The implementation is covered by comprehensive tests that verify: + +### Basic Operations +- Element insertion and search +- Empty tree operations +- Size tracking +- Non-intersecting area queries + +### Edge Cases +- Points (zero-sized AABBs) +- Negative coordinates +- Very large numbers (1e6 - 1e7) +- Very small numbers (1e-6 - 1e-5) +- Boundary intersections (touching edges) +- Identical AABBs + +### Structural Tests +- Node splitting when exceeding maxEntries +- Multi-level tree structure +- Deep trees (500+ elements) +- Degenerate cases (lines) +- Tree rebalancing + +### Performance +- Search in large datasets (50,000 elements) +- Efficiency with multiple intersections +- Deep structure operations +- Comparison with naive search +- Various search area sizes + +## Usage +```kotlin +// Create tree with custom node size +val rtree = Rtree(maxEntries = 16, minEntries = 4) + +// Insert elements +rtree.insert(AABB(0f, 0f, 10f, 10f), "item1") + +// Search for intersections +val results = rtree.search(AABB(5f, 5f, 15f, 15f)) + +// Get size +val size = rtree.size() + +// Get tree depth +val depth = rtree.depth() +``` + +## Running Tests +```bash +./gradlew :composeApp:cleanJvmTest :composeApp:jvmTest --tests "ovh.plrapps.mapcompose.maplibre.utils.rtree.RtreeTest" +``` + +## Notes +- Implementation uses Kotlin Compose structures +- R-tree is used for fast candidate selection by AABB +- Precise intersection checks should be performed separately (e.g., using OBB) only for selected candidates +- Default node size (maxEntries=16, minEntries=4) provides good balance between search performance and memory usage +- Tree depth is automatically maintained during insertion + +## Algorithm +Detailed description of the R-tree algorithm can be found on [Wikipedia](https://en.wikipedia.org/wiki/R-tree) diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/rtree/Rtree.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/rtree/Rtree.kt new file mode 100644 index 00000000..b8e289a7 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/utils/rtree/Rtree.kt @@ -0,0 +1,278 @@ +package ovh.plrapps.mapcompose.vector.utils.rtree + +sealed class RTreeNode { + data class Leaf( + val entries: MutableList> = mutableListOf() + ) : RTreeNode() + + data class NonLeaf( + val children: MutableList, AABB>> = mutableListOf() + ) : RTreeNode() +} + +class Rtree( + private val maxEntries: Int = DEFAULT_MAX_ENTRIES, + private val minEntries: Int = DEFAULT_MIN_ENTRIES, + private val maxDepth: Int = DEFAULT_MAX_DEPTH +) { + companion object { + private const val DEFAULT_MAX_ENTRIES = 32 + private const val DEFAULT_MIN_ENTRIES = 2 + private const val DEFAULT_MAX_DEPTH = Int.MAX_VALUE + private val EMPTY_AABB = AABB(0f, 0f, 0f, 0f) + } + + private var root: RTreeNode = RTreeNode.Leaf() + private var size: Int = 0 + private var depth: Int = 0 + private val tempResults = mutableSetOf() + private val searchStack = mutableListOf>() + + fun insert(bounds: AABB, item: T) { + val splitResult = insertInternal(root, bounds, item, 0) + if (splitResult != null) { + val newRoot = RTreeNode.NonLeaf() + for (child in splitResult) { + newRoot.children.add(child to calcBounds(child)) + } + root = newRoot + depth++ + } + size++ + } + + private fun chooseSubtree(node: RTreeNode.NonLeaf, bounds: AABB): Int { + var bestChild = 0 + var minOverlap = Float.MAX_VALUE + var minArea = Float.MAX_VALUE + + for (i in node.children.indices) { + val (_, childBounds) = node.children[i] + val newBounds = childBounds.union(bounds) + + var overlap = 0f + for (j in node.children.indices) { + if (i != j) { + val (_, otherBounds) = node.children[j] + if (newBounds.intersects(otherBounds)) { + overlap += newBounds.intersectionArea(otherBounds) + } + } + } + + if (overlap < minOverlap) { + minOverlap = overlap + minArea = newBounds.area() - childBounds.area() + bestChild = i + } else if (overlap == minOverlap) { + // With equal overlap, we select the minimum increase in area + val areaIncrease = newBounds.area() - childBounds.area() + if (areaIncrease < minArea) { + minArea = areaIncrease + bestChild = i + } + } + } + + return bestChild + } + + private fun findSeeds(items: List, getBounds: (T) -> AABB): Pair { + var maxDistance = Float.MIN_VALUE + var seed1 = 0 + var seed2 = 1 + for (i in items.indices) { + for (j in (i + 1) until items.size) { + val bounds1 = getBounds(items[i]) + val bounds2 = getBounds(items[j]) + val distance = bounds1.area() + bounds2.area() - bounds1.union(bounds2).area() + if (distance > maxDistance) { + maxDistance = distance + seed1 = i + seed2 = j + } + } + } + return seed1 to seed2 + } + + private fun insertInternal(node: RTreeNode, bounds: AABB, item: T, currentDepth: Int): List>? { + if (currentDepth >= maxDepth) { + throw IllegalStateException("Maximum tree depth exceeded") + } + + return when (node) { + is RTreeNode.Leaf -> { + node.entries.add(bounds to item) + if (node.entries.size > maxEntries) splitLeaf(node).toList() else null + } + is RTreeNode.NonLeaf -> { + val bestChild = chooseSubtree(node, bounds) + val (child, _) = node.children[bestChild] + val splitResult = insertInternal(child, bounds, item, currentDepth + 1) + + if (splitResult != null) { + node.children.removeAt(bestChild) + for (newChild in splitResult) { + node.children.add(newChild to calcBounds(newChild)) + } + + if (node.children.size > maxEntries) { + splitNonLeaf(node).toList() + } else { + null + } + } else { + node.children[bestChild] = child to calcBounds(child) + null + } + } + } + } + + private fun splitLeaf(node: RTreeNode.Leaf): Pair, RTreeNode> { + if (node.entries.size <= 1) { + return node to RTreeNode.Leaf() + } + + val (seed1, seed2) = findSeeds(node.entries) { it.first } + + val leaf1 = RTreeNode.Leaf() + val leaf2 = RTreeNode.Leaf() + + var bounds1 = node.entries[seed1].first + var bounds2 = node.entries[seed2].first + + leaf1.entries.add(node.entries[seed1]) + leaf2.entries.add(node.entries[seed2]) + + val remaining = node.entries.filterIndexed { idx, _ -> idx != seed1 && idx != seed2 } + .sortedByDescending { entry -> + val area1 = bounds1.union(entry.first).area() - bounds1.area() + val area2 = bounds2.union(entry.first).area() - bounds2.area() + maxOf(area1, area2) + } + + for (entry in remaining) { + val area1 = bounds1.union(entry.first).area() - bounds1.area() + val area2 = bounds2.union(entry.first).area() - bounds2.area() + + if (area1 < area2 || (area1 == area2 && leaf1.entries.size <= leaf2.entries.size)) { + leaf1.entries.add(entry) + bounds1 = bounds1.union(entry.first) + } else { + leaf2.entries.add(entry) + bounds2 = bounds2.union(entry.first) + } + } + + node.entries.clear() + return leaf1 to leaf2 + } + + private fun splitNonLeaf(node: RTreeNode.NonLeaf): Pair, RTreeNode> { + if (node.children.size <= 1) { + return node to RTreeNode.NonLeaf() + } + + val (seed1, seed2) = findSeeds(node.children) { it.second } + + val n1 = RTreeNode.NonLeaf() + val n2 = RTreeNode.NonLeaf() + + var bounds1 = node.children[seed1].second + var bounds2 = node.children[seed2].second + + n1.children.add(node.children[seed1]) + n2.children.add(node.children[seed2]) + + val remaining = node.children.filterIndexed { idx, _ -> idx != seed1 && idx != seed2 } + .sortedByDescending { (_, bound) -> + val area1 = bounds1.union(bound).area() - bounds1.area() + val area2 = bounds2.union(bound).area() - bounds2.area() + maxOf(area1, area2) + } + + for ((child, bound) in remaining) { + val area1 = bounds1.union(bound).area() - bounds1.area() + val area2 = bounds2.union(bound).area() - bounds2.area() + + if (area1 < area2 || (area1 == area2 && n1.children.size <= n2.children.size)) { + n1.children.add(child to bound) + bounds1 = bounds1.union(bound) + } else { + n2.children.add(child to bound) + bounds2 = bounds2.union(bound) + } + } + + node.children.clear() + return n1 to n2 + } + + fun search(bounds: AABB): Set { + if (size == 0) return emptySet() + tempResults.clear() + searchInternal(root, bounds) + return tempResults.toSet() + } + + private fun searchInternal(node: RTreeNode, bounds: AABB) { + searchStack.clear() + searchStack.add(node) + + while (searchStack.isNotEmpty()) { + val current = searchStack.removeAt(searchStack.lastIndex) + when (current) { + is RTreeNode.Leaf -> { + for ((entryBounds, item) in current.entries) { + if (bounds.intersects(entryBounds)) { + tempResults.add(item) + } + } + } + is RTreeNode.NonLeaf -> { + for ((child, childBounds) in current.children) { + if (bounds.intersects(childBounds)) { + searchStack.add(child) + } + } + } + } + } + } + + fun size(): Int = size + + fun depth(): Int = depth + + fun clear() { + root = RTreeNode.Leaf() + size = 0 + depth = 0 + tempResults.clear() + searchStack.clear() + } + + private fun calcBounds(node: RTreeNode): AABB { + return when (node) { + is RTreeNode.Leaf -> { + if (node.entries.isEmpty()) EMPTY_AABB + else node.entries.fold(node.entries[0].first) { acc, (bounds, _) -> acc.union(bounds) } + } + is RTreeNode.NonLeaf -> { + if (node.children.isEmpty()) EMPTY_AABB + else node.children.fold(node.children[0].second) { acc, (_, bounds) -> acc.union(bounds) } + } + } + } +} + +private fun AABB.area(): Float = (maxX - minX) * (maxY - minY) + +private fun AABB.intersectionArea(other: AABB): Float { + if (!intersects(other)) return 0f + val width = minOf(maxX, other.maxX) - maxOf(minX, other.minX) + val height = minOf(maxY, other.maxY) - maxOf(minY, other.minY) + return width * height +} diff --git a/library/src/commonTest/composeResources/files/expression-tests-LICENSE.txt b/library/src/commonTest/composeResources/files/expression-tests-LICENSE.txt new file mode 100644 index 00000000..6669db78 --- /dev/null +++ b/library/src/commonTest/composeResources/files/expression-tests-LICENSE.txt @@ -0,0 +1,116 @@ +Copyright (c) 2020, MapLibre contributors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of MapLibre GL JS nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------------- + +Contains code from mapbox-gl-js v1.13 and earlier + +Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license + +Copyright (c) 2020, Mapbox +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of Mapbox GL JS nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------------- + +Contains code from glfx.js + +Copyright (C) 2011 by Evan Wallace + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +-------------------------------------------------------------------------------- + +Contains a portion of d3-color https://github.com/d3/d3-color + +Copyright 2010-2016 Mike Bostock +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the author nor the names of contributors may be used to + endorse or promote products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/library/src/commonTest/composeResources/files/expression-tests.json b/library/src/commonTest/composeResources/files/expression-tests.json new file mode 100644 index 00000000..6667ab19 --- /dev/null +++ b/library/src/commonTest/composeResources/files/expression-tests.json @@ -0,0 +1 @@ +{"upstreamCommit":"d881eef3be6a3602ff3434e1d9a20067b3fe1a31","tests":{"abs/basic":{"expression":["abs",["get","x"]],"inputs":[[{},{"properties":{"x":-2}}],[{},{"properties":{"x":2}}],[{},{"properties":{"x":-1.1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[2,2,1.1]}},"acos/basic":{"expression":["acos",0.5],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[1.04719]}},"all/arity-0":{"expression":["all"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[true]}},"all/arity-1":{"expression":["all",false],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"all/basic":{"expression":["all",["boolean",["get","x"]],["boolean",["get","y"]]],"inputs":[[{},{"properties":{"x":true,"y":false}}],[{},{"properties":{"x":true,"y":true}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true]}},"all/short-circuiting":{"expression":["all",false,["error","unreachable"]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"any/arity-0":{"expression":["any"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"any/arity-1":{"expression":["any",false],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"any/basic":{"expression":["any",["boolean",["get","x"]],["boolean",["get","y"]]],"inputs":[[{},{"properties":{"x":true,"y":true}}],[{},{"properties":{"x":true,"y":false}}],[{},{"properties":{"x":false,"y":true}}],[{},{"properties":{"x":false,"y":false}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,true,false]}},"any/short-circuiting":{"expression":["any",true,["error","unreachable"]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[true]}},"array/basic":{"expression":["array",["literal",[1,2,3]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[[1,2,3]]}},"array/default-value":{"expression":["array","number",null,["get","x"],["literal",[0]]],"inputs":[[{},{}],[{},{"properties":{"x":[]}}],[{},{"properties":{"x":["1"]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[[0],[],[0]]}},"array/empty":{"expression":["array",["literal",[]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[[]]}},"array/implicit-1":{"expression":["at",0,["get","array"]],"inputs":[[{},{"properties":{"array":[0,1,2]}}],[{},{"properties":{"array":"not"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":[0,{"error":"layers[0].paint.some-property[2]: Expected value to be of type array, but found string instead."}]}},"array/implicit-2":{"propertySpec":{"type":"array","value":"string","property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["get","array"],"inputs":[[{},{"properties":{"array":["a","b"]}}],[{},{"properties":{"array":[1,2]}}],[{},{"properties":{"array":"not"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[["a","b"],{"error":"layers[0].paint.some-property: Expected value to be of type array, but found array instead."},{"error":"layers[0].paint.some-property: Expected value to be of type array, but found string instead."}]}},"array/implicit-3":{"propertySpec":{"type":"array","value":"number","length":2,"property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["get","array"],"inputs":[[{},{"properties":{"array":[1,2]}}],[{},{"properties":{"array":[1,2,3]}}],[{},{"properties":{"array":"not"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[[1,2],{"error":"layers[0].paint.some-property: Expected value to be of type array, but found array instead."},{"error":"layers[0].paint.some-property: Expected value to be of type array, but found string instead."}]}},"array/invalid-array-input/array-items-as-args":{"expression":["array",1,2,3],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"The item type argument of \"array\" must be one of string, number, boolean"}]}}},"array/invalid-array-input/bare-array":{"expression":["array",[1,2,3]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1][0]","error":"Expression name must be a string, but found number instead. If you wanted a literal array, use [\"literal\", [...]]."}]}}},"array/invalid-array-input/bare-array-with-type":{"expression":["array","number",[1,2,3]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[2][0]","error":"Expression name must be a string, but found number instead. If you wanted a literal array, use [\"literal\", [...]]."}]}}},"array/invalid-array-input/bare-array-with-type-and-length":{"expression":["array","number",3,[1,2,3]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[3][0]","error":"Expression name must be a string, but found number instead. If you wanted a literal array, use [\"literal\", [...]]."}]}}},"array/invalid-item-type/expression":{"expression":["array",["literal","number"],["literal",[]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"The item type argument of \"array\" must be one of string, number, boolean"}]}}},"array/invalid-item-type/invalid-string":{"expression":["array","0",["literal",[]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"The item type argument of \"array\" must be one of string, number, boolean"}]}}},"array/invalid-item-type/number":{"expression":["array",0,["literal",[]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"The item type argument of \"array\" must be one of string, number, boolean"}]}}},"array/invalid-length/expression":{"expression":["array","string",["literal",0],["literal",[]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"The length argument to \"array\" must be a positive integer literal"}]}}},"array/invalid-length/negative":{"expression":["array","string",-1,["literal",[]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"The length argument to \"array\" must be a positive integer literal"}]}}},"array/invalid-length/non-integer":{"expression":["array","string",0.5,["literal",[]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"The length argument to \"array\" must be a positive integer literal"}]}}},"array/invalid-length/string":{"expression":["array","string","0",["literal",[]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"The length argument to \"array\" must be a positive integer literal"}]}}},"array/item-type/boolean":{"expression":["array","boolean",["get","x"]],"inputs":[[{},{"properties":{"x":["a","b"]}}],[{},{"properties":{"x":[1,2]}}],[{},{"properties":{"x":[true,false]}}],[{},{"properties":{"x":[true,1,"b"]}}],[{},{"properties":{"x":[]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[{"error":"layers[0].paint.some-property: Expected value to be of type array, but found array instead."},{"error":"layers[0].paint.some-property: Expected value to be of type array, but found array instead."},[true,false],{"error":"layers[0].paint.some-property: Expected value to be of type array, but found array instead."},[]]}},"array/item-type/number":{"expression":["array","number",["get","x"]],"inputs":[[{},{"properties":{"x":["a","b"]}}],[{},{"properties":{"x":[1,2]}}],[{},{"properties":{"x":[true,false]}}],[{},{"properties":{"x":[true,1,"b"]}}],[{},{"properties":{"x":[]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[{"error":"layers[0].paint.some-property: Expected value to be of type array, but found array instead."},[1,2],{"error":"layers[0].paint.some-property: Expected value to be of type array, but found array instead."},{"error":"layers[0].paint.some-property: Expected value to be of type array, but found array instead."},[]]}},"array/item-type/string":{"expression":["array","string",["get","x"]],"inputs":[[{},{"properties":{"x":["a","b"]}}],[{},{"properties":{"x":[1,2]}}],[{},{"properties":{"x":[true,false]}}],[{},{"properties":{"x":[true,1,"b"]}}],[{},{"properties":{"x":[]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[["a","b"],{"error":"layers[0].paint.some-property: Expected value to be of type array, but found array instead."},{"error":"layers[0].paint.some-property: Expected value to be of type array, but found array instead."},{"error":"layers[0].paint.some-property: Expected value to be of type array, but found array instead."},[]]}},"array/length":{"expression":["array","number",2,["get","x"]],"inputs":[[{},{"properties":{"x":[1,0]}}],[{},{"properties":{"x":[0]}}],[{},{"properties":{"x":[1,2,3]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[[1,0],{"error":"layers[0].paint.some-property: Expected value to be of type array, but found array instead."},{"error":"layers[0].paint.some-property: Expected value to be of type array, but found array instead."}]}},"asin/basic":{"expression":["asin",0.5],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[0.523598]}},"at/basic":{"expression":["number",["at",["number",["get","i"]],["array",["get","arr"]]]],"inputs":[[{},{"properties":{"i":1,"arr":[9,8,7]}}],[{},{"properties":{"i":-1,"arr":[9,8,7]}}],[{},{"properties":{"i":4,"arr":[9,8,7]}}],[{},{"properties":{"i":1.5,"arr":[9,8,7]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[8,{"error":"layers[0].paint.some-property[1]: Array index out of bounds: -1 < 0."},{"error":"layers[0].paint.some-property[1]: Array index out of bounds: 4 > 2."},{"error":"layers[0].paint.some-property[1]: Array index must be an integer, but found 1.5 instead."}]}},"at/infer-array-type":{"propertySpec":{"type":"string","property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["at",1,["literal",[1,2,3]]],"inputs":[],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"Expected array but found array instead."}]}}},"atan/basic":{"expression":["atan",1],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[0.785398]}},"boolean/basic":{"expression":["boolean",["get","x"]],"inputs":[[{},{}],[{},{"properties":{"x":true}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":""}}],[{},{"properties":{"x":"false"}}],[{},{"properties":{"x":0}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[{"error":"layers[0].paint.some-property: Expected value to be of type boolean, but found null instead."},true,false,{"error":"layers[0].paint.some-property: Expected value to be of type boolean, but found string instead."},{"error":"layers[0].paint.some-property: Expected value to be of type boolean, but found string instead."},{"error":"layers[0].paint.some-property: Expected value to be of type boolean, but found number instead."},{"error":"layers[0].paint.some-property: Expected value to be of type boolean, but found number instead."},{"error":"layers[0].paint.some-property: Expected value to be of type boolean, but found null instead."}]}},"boolean/default-value":{"expression":["boolean",["get","x"],false],"inputs":[[{},{}],[{},{"properties":{"x":true}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":""}}],[{},{"properties":{"x":"false"}}],[{},{"properties":{"x":0}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,false,false,false,false,false,false]}},"case/basic":{"propertySpec":{"type":"string","property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["case",["get","x"],"x",["get","y"],"y","otherwise"],"inputs":[[{},{"properties":{"x":true,"y":true}}],[{},{"properties":{"x":true,"y":false}}],[{},{"properties":{"x":false,"y":true}}],[{},{"properties":{"x":false,"y":false}}],[{},{"properties":{"x":"false","y":false}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["x","x","y","otherwise",{"error":"layers[0].paint.some-property[1]: Expected value to be of type boolean, but found string instead."}]}},"case/infer-array-type":{"propertySpec":{"type":"array","value":"string","property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["case",["boolean",["get","x"]],["literal",["one"]],["literal",["one","two"]]],"inputs":[],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[]}},"case/interpolated-output":{"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":true,"parameters":["feature"]}},"expression":["case",["has","x"],["interpolate",["linear"],["get","x"],2,"#ccc",10,"#444"],["has","y"],["interpolate",["linear"],["get","y"],0,"black",1,"white"],"#fcaf3e"],"inputs":[[{},{}],[{},{"properties":{"y":0}}],[{},{"properties":{"y":0.5}}],[{},{"properties":{"y":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[0.988235,0.686274,0.243137,1],[0,0,0,1],[0.5,0.5,0.5,1],[1,1,1,1]]}},"case/null-fallback":{"expression":["case",["has","x"],["get","x"],null],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":[null]}},"case/null-output":{"expression":["case",["has","x"],["get","x"],["has","y"],null,"otherwise"],"inputs":[[{},{"properties":{"y":true}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":[null]}},"case/precedence":{"expression":["boolean",["all",false,["case",true,true,true]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"ceil/basic":{"expression":["ceil",["get","x"]],"inputs":[[{},{"properties":{"x":-2.6}}],[{},{"properties":{"x":-2.5}}],[{},{"properties":{"x":-2.4}}],[{},{"properties":{"x":-2}}],[{},{"properties":{"x":2.6}}],[{},{"properties":{"x":2.5}}],[{},{"properties":{"x":2.4}}],[{},{"properties":{"x":2}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-2,-2,-2,-2,3,3,3,2]}},"coalesce/argument-type-mismatch":{"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["coalesce",["get","a"],5],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"Expected string but found number instead."}]}}},"coalesce/basic":{"expression":["coalesce",["get","x"],["get","y"],["get","z"],0],"inputs":[[{},{"properties":{"x":1}}],[{},{"properties":{"x":1,"y":2,"z":3}}],[{},{"properties":{"y":2}}],[{},{"properties":{"z":3}}],[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":[1,1,2,3,0]}},"coalesce/error":{"expression":["coalesce",["number",["get","x"]],["number",["get","y"]],["number",["get","z"]]],"inputs":[[{},{"properties":{"x":1}}],[{},{"properties":{"x":1,"y":2,"z":3}}],[{},{"properties":{"y":2}}],[{},{"properties":{"z":3}}],[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[1,1,{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found null instead."},{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found null instead."},{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found null instead."}]}},"coalesce/infer-array-type":{"propertySpec":{"type":"array","value":"string","property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["coalesce",["literal",["one"]],["literal",["one","two"]],null],"inputs":[],"expected":{"compiled":{"result":"error","errors":[{"key":"[3]","error":"Expected array but found null instead."}]}}},"coalesce/inference":{"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["coalesce",["get","a"],["get","b"]],"inputs":[[{},{"properties":{"a":"one"}}],[{},{"properties":{"b":"two"}}],[{},{"properties":{"b":5}}],[{},{"properties":{}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["one","two","5",""]}},"coalesce/null":{"expectExpressionType":null,"expression":["coalesce",["get","z"],0],"inputs":[[{},{"properties":{"z":1}}],[{},{"properties":{"z":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":[1,0]}},"collator/accent-equals-de":{"expression":["case",["==",["resolved-locale",["collator",{"case-sensitive":true,"diacritic-sensitive":false,"locale":"de"}]],"de"],["==",["string",["get","lhs"]],["get","rhs"],["collator",{"case-sensitive":true,"diacritic-sensitive":false,"locale":"de"}]],["case",["==",["get","rhs"],"ue"],true,false]],"inputs":[[{},{"properties":{"lhs":"\u00fc","rhs":"ue"}}],[{},{"properties":{"lhs":"\u00fc","rhs":"u"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false]}},"collator/accent-lt-en":{"expression":["<",["string",["get","lhs"]],["get","rhs"],["collator",{"case-sensitive":true,"diacritic-sensitive":false,"locale":"en"}]],"inputs":[[{},{"properties":{"lhs":"a","rhs":"\u00e4"}}],[{},{"properties":{"lhs":"a","rhs":"A"}}],[{},{"properties":{"lhs":"\u00e4","rhs":"b"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,true]}},"collator/accent-not-equals-en":{"expression":["!",["!=",["string",["get","lhs"]],["get","rhs"],["collator",{"case-sensitive":true,"diacritic-sensitive":false,"locale":"en"}]]],"inputs":[[{},{"properties":{"lhs":"a","rhs":"\u00e4"}}],[{},{"properties":{"lhs":"a","rhs":"A"}}],[{},{"properties":{"lhs":"b","rhs":"\u00e4"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false,false]}},"collator/base-default-locale":{"expression":["==",["string",["get","lhs"]],["get","rhs"],["collator",{"case-sensitive":false,"diacritic-sensitive":false}]],"inputs":[[{},{"properties":{"lhs":"a","rhs":"a"}}],[{},{"properties":{"lhs":"A","rhs":"A"}}],[{},{"properties":{"lhs":"b","rhs":"a"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,false]}},"collator/base-equals-en":{"expression":["==",["string",["get","lhs"]],["get","rhs"],["collator",{"case-sensitive":false,"diacritic-sensitive":false,"locale":"en"}]],"inputs":[[{},{"properties":{"lhs":"a","rhs":"\u00e4"}}],[{},{"properties":{"lhs":"a","rhs":"A"}}],[{},{"properties":{"lhs":"b","rhs":"\u00e4"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,false]}},"collator/base-gt-en":{"expression":[">",["string",["get","lhs"]],["get","rhs"],["collator",{"case-sensitive":false,"diacritic-sensitive":false,"locale":"en"}]],"inputs":[[{},{"properties":{"lhs":"a","rhs":"\u00e4"}}],[{},{"properties":{"lhs":"a","rhs":"A"}}],[{},{"properties":{"lhs":"b","rhs":"\u00e4"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,true]}},"collator/case-lteq-en":{"expression":["<=",["string",["get","lhs"]],["get","rhs"],["collator",{"case-sensitive":false,"diacritic-sensitive":true,"locale":"en"}]],"inputs":[[{},{"properties":{"lhs":"\u00e4","rhs":"a"}}],[{},{"properties":{"lhs":"A","rhs":"a"}}],[{},{"properties":{"lhs":"a","rhs":"a"}}],[{},{"properties":{"lhs":"\u00e4","rhs":"b"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,true,true]}},"collator/case-not-equals-en":{"expression":["!",["!=",["string",["get","lhs"]],["get","rhs"],["collator",{"case-sensitive":false,"diacritic-sensitive":true,"locale":"en"}]]],"inputs":[[{},{"properties":{"lhs":"a","rhs":"\u00e4"}}],[{},{"properties":{"lhs":"a","rhs":"A"}}],[{},{"properties":{"lhs":"b","rhs":"\u00e4"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,false]}},"collator/case-omitted-en":{"expression":["==",["string",["get","lhs"]],["get","rhs"],["collator",{"diacritic-sensitive":true,"locale":"en"}]],"inputs":[[{},{"properties":{"lhs":"\u00e4","rhs":"a"}}],[{},{"properties":{"lhs":"A","rhs":"a"}}],[{},{"properties":{"lhs":"a","rhs":"a"}}],[{},{"properties":{"lhs":"\u00e4","rhs":"b"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,true,false]}},"collator/comparison-number-error":{"expression":["<",1,2,["collator",{"case-sensitive":false,"diacritic-sensitive":false}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Cannot use collator to compare non-string types."}]}}},"collator/diacritic-omitted-en":{"expression":["<",["string",["get","lhs"]],["get","rhs"],["collator",{"case-sensitive":["==",1,1],"locale":"en"}]],"inputs":[[{},{"properties":{"lhs":"a","rhs":"\u00e4"}}],[{},{"properties":{"lhs":"a","rhs":"A"}}],[{},{"properties":{"lhs":"\u00e4","rhs":"b"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,true]}},"collator/equals-non-string-error":{"expression":["==",1,2,["collator",{"case-sensitive":false,"diacritic-sensitive":false}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Cannot use collator to compare non-string types."}]}}},"collator/non-object-error":{"expression":["==","foo","bar",["collator",["subexpression"]]],"expected":{"compiled":{"result":"error","errors":[{"key":"[3]","error":"Collator options argument must be an object."}]}}},"collator/variable-equals":{"expression":["let","myVariable",["collator",{"diacritic-sensitive":["get","diacritic-sensitive"]}],["==","t\u0113n\u0101","tena",["var","myVariable"]]],"inputs":[[{},{"properties":{"diacritic-sensitive":true}}],[{},{"properties":{"diacritic-sensitive":false}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true]}},"collator/variable-gt":{"expression":["let","myVariable",["collator",{"locale":["get","locale"]}],[">","\u00e4","a",["var","myVariable"]]],"inputs":[[{},{"properties":{"locale":"de"}}],[{},{"properties":{"locale":"sv"}}],[{},{"properties":{"locale":"dk"}}],[{},{"properties":{"locale":"fr"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,false,false]}},"collator/variable-lteq":{"expression":["let","myVariable",["collator",{"locale":["get","locale"]}],["<=","\u00e4","a",["var","myVariable"]]],"inputs":[[{},{"properties":{"locale":"de"}}],[{},{"properties":{"locale":"sv"}}],[{},{"properties":{"locale":"dk"}}],[{},{"properties":{"locale":"fr"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,true,true]}},"collator/variable-not-equals":{"expression":["let","myVariable",["collator",{"diacritic-sensitive":["get","diacritic-sensitive"]}],["!=","t\u0113n\u0101","tena",["var","myVariable"]]],"inputs":[[{},{"properties":{"diacritic-sensitive":true}}],[{},{"properties":{"diacritic-sensitive":false}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false]}},"collator/variant-equals-en":{"expression":["==",["string",["get","lhs"]],["get","rhs"],["collator",{"case-sensitive":true,"diacritic-sensitive":true,"locale":"en"}]],"inputs":[[{},{"properties":{"lhs":"a","rhs":"\u00e4"}}],[{},{"properties":{"lhs":"a","rhs":"A"}}],[{},{"properties":{"lhs":"b","rhs":"\u00e4"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,false]}},"collator/variant-gteq-en":{"expression":[">=",["string",["get","lhs"]],["get","rhs"],["collator",{"case-sensitive":true,"diacritic-sensitive":true,"locale":"en"}]],"inputs":[[{},{"properties":{"lhs":"a","rhs":"\u00e4"}}],[{},{"properties":{"lhs":"a","rhs":"A"}}],[{},{"properties":{"lhs":"a","rhs":"a"}}],[{},{"properties":{"lhs":"b","rhs":"\u00e4"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,true,true]}},"concat/arity-0":{"expression":["concat"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":[""]}},"concat/arity-1":{"expression":["concat","foo"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["foo"]}},"concat/basic":{"expression":["concat","a","b","c"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["abc"]}},"concat/coercion":{"expression":["concat",["get","a"],["get","1"],["get","true"]],"inputs":[[{},{"properties":{"1":1,"a":"a","true":true}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["a1true"]}},"constant-folding/evaluation-error":{"propertySpec":{"type":"color","property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["step",["get","x"],"black",0,"invalid",10,"blue"],"inputs":[[{},{"properties":{"x":-1}}],[{},{"properties":{"x":0}}],[{},{"properties":{"x":5}}],[{},{"properties":{"x":10}}],[{},{"properties":{"x":11}}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Could not parse color from value 'invalid'"}]}}},"constant-folding/to-color":{"expression":["to-color","red"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"color"},"outputs":[[1,0,0,1]]}},"constant-folding/to-color-inferred":{"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":"red","inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"color"},"outputs":[[1,0,0,1]]}},"constant-folding/var":{"expression":["let","a",1,"b",2,["+",["+",["var","a"],["var","b"]],["var","a"]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[4]}},"cos/basic":{"expression":["cos",0],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[1]}},"distance/basic":{"expression":["distance",{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[3,3]}}]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[3,3]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0.00170188]}},"distance/expression-geometry":{"expression":["distance",["literal",{"type":"MultiPoint","coordinates":[[3,3],[3,4]]}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"'distance' expression requires valid geojson object that contains polygon geometry type."}]}}},"distance/from-line/to-line-parallel":{"expression":["distance",{"type":"LineString","coordinates":[[3,3],[3,4]]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[3.001,3],[3.001,4]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[111.167]}},"distance/from-line/to-line-perpendicular":{"expression":["distance",{"type":"LineString","coordinates":[[3,3],[3,4]]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[2.5,3.5],[3.5,3.5]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"distance/from-line/to-point-nearby-horizontally":{"expression":["distance",{"type":"LineString","coordinates":[[3,3],[3,4]]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[3,2.999]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[110.577]}},"distance/from-line/to-point-nearby-vertically":{"expression":["distance",{"type":"LineString","coordinates":[[3,3],[3,4]]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[3.001,3]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[111.167]}},"distance/from-line/to-point-on-line":{"expression":["distance",{"type":"LineString","coordinates":[[3,3],[3,4]]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[3,3]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0.00124238]}},"distance/from-line/to-point-on-line-middle":{"expression":["distance",{"type":"LineString","coordinates":[[3,3],[3,4]]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[3,3.5]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0.00124178]}},"distance/from-line/to-points":{"expression":["distance",{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[3,3],[3,3.005],[3,3.01],[3,3.015],[3,3.02],[3,3.025],[3,3.03],[3,3.035],[3,3.04],[3,3.045],[3,3.05],[3,3.055],[3,3.06],[3,3.065],[3,3.07],[3,3.075],[3,3.08],[3,3.085],[3,3.09],[3,3.095],[3,3.1],[3,3.105],[3,3.11],[3,3.115],[3,3.12],[3,3.125],[3,3.13],[3,3.135],[3,3.14],[3,3.145],[3,3.15],[3,3.155],[3,3.16],[3,3.165],[3,3.17],[3,3.175],[3,3.18],[3,3.185],[3,3.19],[3,3.195],[3,3.2],[3,3.205],[3,3.21],[3,3.215],[3,3.22],[3,3.225],[3,3.23],[3,3.235],[3,3.24],[3,3.245],[3,3.25],[3,3.255],[3,3.26],[3,3.265],[3,3.27],[3,3.275],[3,3.28],[3,3.285],[3,3.29],[3,3.295],[3,3.3],[3,3.305],[3,3.31],[3,3.315],[3,3.32],[3,3.325],[3,3.33],[3,3.335],[3,3.34],[3,3.345],[3,3.35],[3,3.355],[3,3.36],[3,3.365],[3,3.37],[3,3.375],[3,3.38],[3,3.385],[3,3.39],[3,3.395],[3,3.4],[3,3.405],[3,3.41],[3,3.415],[3,3.42],[3,3.425],[3,3.43],[3,3.435],[3,3.44],[3,3.445],[3,3.45],[3,3.455],[3,3.46],[3,3.465],[3,3.47],[3,3.475],[3,3.48],[3,3.485],[3,3.49],[3,3.495],[3,3.5],[3,3.505],[3,3.51],[3,3.515],[3,3.52],[3,3.525],[3,3.53],[3,3.535],[3,3.54],[3,3.545],[3,3.55],[3,3.555],[3,3.56],[3,3.565],[3,3.57],[3,3.575],[3,3.58],[3,3.585],[3,3.59],[3,3.595],[3,3.6],[3,3.605],[3,3.61],[3,3.615],[3,3.62],[3,3.625],[3,3.63],[3,3.635],[3,3.64],[3,3.645],[3,3.65],[3,3.655],[3,3.66],[3,3.665],[3,3.67],[3,3.675],[3,3.68],[3,3.685],[3,3.69],[3,3.695],[3,3.7],[3,3.705],[3,3.71],[3,3.715],[3,3.72],[3,3.725],[3,3.73],[3,3.735],[3,3.74],[3,3.745],[3,3.75],[3,3.755],[3,3.76],[3,3.765],[3,3.77],[3,3.775],[3,3.78],[3,3.785],[3,3.79],[3,3.795],[3,3.8],[3,3.805],[3,3.81],[3,3.815],[3,3.82],[3,3.825],[3,3.83],[3,3.835],[3,3.84],[3,3.845],[3,3.85],[3,3.855],[3,3.86],[3,3.865],[3,3.87],[3,3.875],[3,3.88],[3,3.885],[3,3.89],[3,3.895],[3,3.9],[3,3.905],[3,3.91],[3,3.915],[3,3.92],[3,3.925],[3,3.93],[3,3.935],[3,3.94],[3,3.945],[3,3.95],[3,3.955],[3,3.96],[3,3.965],[3,3.97],[3,3.975],[3,3.98],[3,3.985],[3,3.99],[3,3.995]]},"properties":{}}]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"MultiPoint","coordinates":[[2.999,3],[2.999,3.005],[2.999,3.01],[2.999,3.015],[2.999,3.02],[2.999,3.025],[2.999,3.03],[2.999,3.035],[2.999,3.04],[2.999,3.045],[2.999,3.05],[2.999,3.055],[2.999,3.06],[2.999,3.065],[2.999,3.07],[2.999,3.075],[2.999,3.08],[2.999,3.085],[2.999,3.09],[2.999,3.095],[2.999,3.1],[2.999,3.105],[2.999,3.11],[2.999,3.115],[2.999,3.12],[2.999,3.125],[2.999,3.13],[2.999,3.135],[2.999,3.14],[2.999,3.145],[2.999,3.15],[2.999,3.155],[2.999,3.16],[2.999,3.165],[2.999,3.17],[2.999,3.175],[2.999,3.18],[2.999,3.185],[2.999,3.19],[2.999,3.195],[2.999,3.2],[2.999,3.205],[2.999,3.21],[2.999,3.215],[2.999,3.22],[2.999,3.225],[2.999,3.23],[2.999,3.235],[2.999,3.24],[2.999,3.245],[2.999,3.25],[2.999,3.255],[2.999,3.26],[2.999,3.265],[2.999,3.27],[2.999,3.275],[2.999,3.28],[2.999,3.285],[2.999,3.29],[2.999,3.295],[2.999,3.3],[2.999,3.305],[2.999,3.31],[2.999,3.315],[2.999,3.32],[2.999,3.325],[2.999,3.33],[2.999,3.335],[2.999,3.34],[2.999,3.345],[2.999,3.35],[2.999,3.355],[2.999,3.36],[2.999,3.365],[2.999,3.37],[2.999,3.375],[2.999,3.38],[2.999,3.385],[2.999,3.39],[2.999,3.395],[2.999,3.4],[2.999,3.405],[2.999,3.41],[2.999,3.415],[2.999,3.42],[2.999,3.425],[2.999,3.43],[2.999,3.435],[2.999,3.44],[2.999,3.445],[2.999,3.45],[2.999,3.455],[2.999,3.46],[2.999,3.465],[2.999,3.47],[2.999,3.475],[2.999,3.48],[2.999,3.485],[2.999,3.49],[2.999,3.495],[2.999,3.5],[2.999,3.505],[2.999,3.51],[2.999,3.515],[2.999,3.52],[2.999,3.525],[2.999,3.53],[2.999,3.535],[2.999,3.54],[2.999,3.545],[2.999,3.55],[2.999,3.555],[2.999,3.56],[2.999,3.565],[2.999,3.57],[2.999,3.575],[2.999,3.58],[2.999,3.585],[2.999,3.59],[2.999,3.595],[2.999,3.6],[2.999,3.605],[2.999,3.61],[2.999,3.615],[2.999,3.62],[2.999,3.625],[2.999,3.63],[2.999,3.635],[2.999,3.64],[2.999,3.645],[2.999,3.65],[2.999,3.655],[2.999,3.66],[2.999,3.665],[2.999,3.67],[2.999,3.675],[2.999,3.68],[2.999,3.685],[2.999,3.69],[2.999,3.695],[2.999,3.7],[2.999,3.705],[2.999,3.71],[2.999,3.715],[2.999,3.72],[2.999,3.725],[2.999,3.73],[2.999,3.735],[2.999,3.74],[2.999,3.745],[2.999,3.75],[2.999,3.755],[2.999,3.76],[2.999,3.765],[2.999,3.77],[2.999,3.775],[2.999,3.78],[2.999,3.785],[2.999,3.79],[2.999,3.795],[2.999,3.8],[2.999,3.805],[2.999,3.81],[2.999,3.815],[2.999,3.82],[2.999,3.825],[2.999,3.83],[2.999,3.835],[2.999,3.84],[2.999,3.845],[2.999,3.85],[2.999,3.855],[2.999,3.86],[2.999,3.865],[2.999,3.87],[2.999,3.875],[2.999,3.88],[2.999,3.885],[2.999,3.89],[2.999,3.895],[2.999,3.9],[2.999,3.905],[2.999,3.91],[2.999,3.915],[2.999,3.92],[2.999,3.925],[2.999,3.93],[2.999,3.935],[2.999,3.94],[2.999,3.945],[2.999,3.95],[2.999,3.955],[2.999,3.96],[2.999,3.965],[2.999,3.97],[2.999,3.975],[2.999,3.98],[2.999,3.985],[2.999,3.99],[2.999,3.995]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[111.169]}},"distance/from-line/to-polygon-contained":{"expression":["distance",{"type":"LineString","coordinates":[[3,3],[3,4]]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"distance/from-line/to-polygon-hole":{"expression":["distance",{"type":"LineString","coordinates":[[3,3],[3,4]]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]],[[2.999,2.999],[3.001,2.999],[3.001,4.001],[2.999,4.001],[2.999,2.999]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[110.574]}},"distance/from-line/to-polygon-nearby":{"expression":["distance",{"type":"LineString","coordinates":[[3,3],[3,4]]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,2.999],[3,2.999],[3,0],[0,0]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[110.574]}},"distance/from-point/to-line-nearby":{"expression":["distance",{"type":"Point","coordinates":[3,3]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[2,2.001],[4,4.001]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[78.4273]}},"distance/from-point/to-line-through-point-horizontal":{"expression":["distance",{"type":"Point","coordinates":[3,3]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[2,3],[4,3]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0.00116311]}},"distance/from-point/to-line-through-point-vertical":{"expression":["distance",{"type":"Point","coordinates":[3,3]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[3,2],[3,4]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0.00124333]}},"distance/from-point/to-point-different-location":{"expression":["distance",{"type":"Point","coordinates":[3,3]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[3,3.001]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[110.575]}},"distance/from-point/to-point-same-location":{"expression":["distance",{"type":"Point","coordinates":[3,3]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[3,3]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0.00170188]}},"distance/from-point/to-polygon-contained":{"expression":["distance",{"type":"Point","coordinates":[3,3]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"distance/from-point/to-polygon-hole":{"expression":["distance",{"type":"Point","coordinates":[3,3]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]],[[2.999,2.999],[3.001,2.999],[3.001,3.001],[2.999,3.001],[2.999,2.999]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[110.572]}},"distance/from-point/to-polygon-nearby":{"expression":["distance",{"type":"Point","coordinates":[3,3]}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,2.999],[3,2.999],[3,0],[0,0]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[110.574]}},"distance/from-polygon-with-hole/to-line-in-hole":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]],[[2.999,2.999],[2.999,4.001],[4.001,4.001],[4.001,2.999],[2.999,2.999]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[3,3],[3,4]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[110.578]}},"distance/from-polygon-with-hole/to-line-in-polygon":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]],[[2.999,2.999],[2.999,4.001],[4.001,4.001],[4.001,2.999],[2.999,2.999]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[2.5,3.5],[3.5,3.5]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"distance/from-polygon-with-hole/to-point-in-hole":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]],[[2.999,2.999],[2.999,4.001],[4.001,4.001],[4.001,2.999],[2.999,2.999]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[3,3]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[110.578]}},"distance/from-polygon-with-hole/to-point-in-polygon":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]],[[2.999,2.999],[2.999,4.001],[4.001,4.001],[4.001,2.999],[2.999,2.999]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[2.999,2.999]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"distance/from-polygon-with-hole/to-polygon-contained":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]],[[2.999,2.999],[2.999,4.001],[4.001,4.001],[4.001,2.999],[2.999,2.999]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"distance/from-simple-polygon/to-line-intersect":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3,3],[3,4],[4,4],[4,3],[3,3]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[2.5,3.5],[3.5,3.5]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"distance/from-simple-polygon/to-line-outside":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3,3],[3,4],[4,4],[4,3],[3,3]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[2.999,3],[2.999,4]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[111.169]}},"distance/from-simple-polygon/to-point-inside":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3,3],[3,4],[4,4],[4,3],[3,3]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[3.001,3.001]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"distance/from-simple-polygon/to-point-on-edge":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3,3],[3,4],[4,4],[4,3],[3,3]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[3,3]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0.00124238]}},"distance/from-simple-polygon/to-point-outside":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3,3],[3,4],[4,4],[4,3],[3,3]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[2.999,3]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[111.169]}},"distance/from-simple-polygon/to-points-outside":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3,3],[3,4],[4,4],[4,3],[3,3]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"MultiPoint","coordinates":[[2.999,3],[2.999,2.999],[2.999,2.998],[2.999,2.997],[2.999,2.996],[2.999,2.995],[2.999,2.994],[2.999,2.993],[2.999,2.992],[2.999,2.991],[2.999,2.99],[2.999,2.989],[2.999,2.988],[2.999,2.987],[2.999,2.986],[2.999,2.985],[2.999,2.984],[2.999,2.983],[2.999,2.982],[2.999,2.981],[2.999,2.98],[2.999,2.979],[2.999,2.978],[2.999,2.977],[2.999,2.976],[2.999,2.975],[2.999,2.974],[2.999,2.973],[2.999,2.972],[2.999,2.971],[2.999,2.97],[2.999,2.969],[2.999,2.968],[2.999,2.967],[2.999,2.966],[2.999,2.965],[2.999,2.964],[2.999,2.963],[2.999,2.962],[2.999,2.961],[2.999,2.96],[2.999,2.959],[2.999,2.958],[2.999,2.957],[2.999,2.956],[2.999,2.955],[2.999,2.954],[2.999,2.953],[2.999,2.952],[2.999,2.951],[2.999,2.95],[2.999,2.949],[2.999,2.948],[2.999,2.947],[2.999,2.946],[2.999,2.945],[2.999,2.944],[2.999,2.943],[2.999,2.942],[2.999,2.941],[2.999,2.94],[2.999,2.939],[2.999,2.938],[2.999,2.937],[2.999,2.936],[2.999,2.935],[2.999,2.934],[2.999,2.933],[2.999,2.932],[2.999,2.931],[2.999,2.93],[2.999,2.929],[2.999,2.928],[2.999,2.927],[2.999,2.926],[2.999,2.925],[2.999,2.924],[2.999,2.923],[2.999,2.922],[2.999,2.921],[2.999,2.92],[2.999,2.919],[2.999,2.918],[2.999,2.917],[2.999,2.916],[2.999,2.915],[2.999,2.914],[2.999,2.913],[2.999,2.912],[2.999,2.911],[2.999,2.91],[2.999,2.909],[2.999,2.908],[2.999,2.907],[2.999,2.906],[2.999,2.905],[2.999,2.904],[2.999,2.903],[2.999,2.902],[2.999,2.901],[2.999,2.9],[2.999,2.899],[2.999,2.898],[2.999,2.897],[2.999,2.896],[2.999,2.895],[2.999,2.894],[2.999,2.893],[2.999,2.892],[2.999,2.891],[2.999,2.89],[2.999,2.889],[2.999,2.888],[2.999,2.887],[2.999,2.886],[2.999,2.885],[2.999,2.884],[2.999,2.883],[2.999,2.882],[2.999,2.881],[2.999,2.88],[2.999,2.879],[2.999,2.878],[2.999,2.877],[2.999,2.876],[2.999,2.875],[2.999,2.874],[2.999,2.873],[2.999,2.872],[2.999,2.871],[2.999,2.87],[2.999,2.869],[2.999,2.868],[2.999,2.867],[2.999,2.866],[2.999,2.865],[2.999,2.864],[2.999,2.863],[2.999,2.862],[2.999,2.861],[2.999,2.86],[2.999,2.859],[2.999,2.858],[2.999,2.857],[2.999,2.856],[2.999,2.855],[2.999,2.854],[2.999,2.853],[2.999,2.852],[2.999,2.851],[2.999,2.85],[2.999,2.849],[2.999,2.848],[2.999,2.847],[2.999,2.846],[2.999,2.845],[2.999,2.844],[2.999,2.843],[2.999,2.842],[2.999,2.841],[2.999,2.84],[2.999,2.839],[2.999,2.838],[2.999,2.837],[2.999,2.836],[2.999,2.835],[2.999,2.834],[2.999,2.833],[2.999,2.832],[2.999,2.831],[2.999,2.83],[2.999,2.829],[2.999,2.828],[2.999,2.827],[2.999,2.826],[2.999,2.825],[2.999,2.824],[2.999,2.823],[2.999,2.822],[2.999,2.821],[2.999,2.82],[2.999,2.819],[2.999,2.818],[2.999,2.817],[2.999,2.816],[2.999,2.815],[2.999,2.814],[2.999,2.813],[2.999,2.812],[2.999,2.811],[2.999,2.81],[2.999,2.809],[2.999,2.808],[2.999,2.807],[2.999,2.806],[2.999,2.805],[2.999,2.804],[2.999,2.803],[2.999,2.802],[2.999,2.801]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[111.169]}},"distance/from-simple-polygon/to-points-outside-and-inside":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3,3],[3,4],[4,4],[4,3],[3,3]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"MultiPoint","coordinates":[[2.999,3],[2.999,2.999],[2.999,2.998],[2.999,2.997],[2.999,2.996],[2.999,2.995],[2.999,2.994],[2.999,2.993],[2.999,2.992],[2.999,2.991],[2.999,2.99],[2.999,2.989],[2.999,2.988],[2.999,2.987],[2.999,2.986],[2.999,2.985],[2.999,2.984],[2.999,2.983],[2.999,2.982],[2.999,2.981],[2.999,2.98],[2.999,2.979],[2.999,2.978],[2.999,2.977],[2.999,2.976],[2.999,2.975],[2.999,2.974],[2.999,2.973],[2.999,2.972],[2.999,2.971],[2.999,2.97],[2.999,2.969],[2.999,2.968],[2.999,2.967],[2.999,2.966],[2.999,2.965],[2.999,2.964],[2.999,2.963],[2.999,2.962],[2.999,2.961],[2.999,2.96],[2.999,2.959],[2.999,2.958],[2.999,2.957],[2.999,2.956],[2.999,2.955],[2.999,2.954],[2.999,2.953],[2.999,2.952],[2.999,2.951],[2.999,2.95],[2.999,2.949],[2.999,2.948],[2.999,2.947],[2.999,2.946],[2.999,2.945],[2.999,2.944],[2.999,2.943],[2.999,2.942],[2.999,2.941],[2.999,2.94],[2.999,2.939],[2.999,2.938],[2.999,2.937],[2.999,2.936],[2.999,2.935],[2.999,2.934],[2.999,2.933],[2.999,2.932],[2.999,2.931],[2.999,2.93],[2.999,2.929],[2.999,2.928],[2.999,2.927],[2.999,2.926],[2.999,2.925],[2.999,2.924],[2.999,2.923],[2.999,2.922],[2.999,2.921],[2.999,2.92],[2.999,2.919],[2.999,2.918],[2.999,2.917],[2.999,2.916],[2.999,2.915],[2.999,2.914],[2.999,2.913],[2.999,2.912],[2.999,2.911],[2.999,2.91],[2.999,2.909],[2.999,2.908],[2.999,2.907],[2.999,2.906],[2.999,2.905],[2.999,2.904],[2.999,2.903],[2.999,2.902],[2.999,2.901],[2.999,2.9],[2.999,2.899],[2.999,2.898],[2.999,2.897],[2.999,2.896],[2.999,2.895],[2.999,2.894],[2.999,2.893],[2.999,2.892],[2.999,2.891],[2.999,2.89],[2.999,2.889],[2.999,2.888],[2.999,2.887],[2.999,2.886],[2.999,2.885],[2.999,2.884],[2.999,2.883],[2.999,2.882],[2.999,2.881],[2.999,2.88],[2.999,2.879],[2.999,2.878],[2.999,2.877],[2.999,2.876],[2.999,2.875],[2.999,2.874],[2.999,2.873],[2.999,2.872],[2.999,2.871],[2.999,2.87],[2.999,2.869],[2.999,2.868],[2.999,2.867],[2.999,2.866],[2.999,2.865],[2.999,2.864],[2.999,2.863],[2.999,2.862],[2.999,2.861],[2.999,2.86],[2.999,2.859],[2.999,2.858],[2.999,2.857],[2.999,2.856],[2.999,2.855],[2.999,2.854],[2.999,2.853],[2.999,2.852],[2.999,2.851],[2.999,2.85],[2.999,2.849],[2.999,2.848],[2.999,2.847],[2.999,2.846],[2.999,2.845],[2.999,2.844],[2.999,2.843],[2.999,2.842],[2.999,2.841],[2.999,2.84],[2.999,2.839],[2.999,2.838],[2.999,2.837],[2.999,2.836],[2.999,2.835],[2.999,2.834],[2.999,2.833],[2.999,2.832],[2.999,2.831],[2.999,2.83],[2.999,2.829],[2.999,2.828],[2.999,2.827],[2.999,2.826],[2.999,2.825],[2.999,2.824],[2.999,2.823],[2.999,2.822],[2.999,2.821],[2.999,2.82],[2.999,2.819],[2.999,2.818],[2.999,2.817],[2.999,2.816],[2.999,2.815],[2.999,2.814],[2.999,2.813],[2.999,2.812],[2.999,2.811],[2.999,2.81],[2.999,2.809],[2.999,2.808],[2.999,2.807],[2.999,2.806],[2.999,2.805],[2.999,2.804],[2.999,2.803],[2.999,2.802],[2.999,2.801],[3.001,3.001]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"distance/from-simple-polygon/to-polygon-contained":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3,3],[3,4],[4,4],[4,3],[3,3]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Polygon","coordinates":[[[3.5,3.5],[3.5,3.6],[3.6,3.6],[3.6,3.5],[3.5,3.5]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"distance/from-simple-polygon/to-polygon-hole":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3,3],[3,4],[4,4],[4,3],[3,3]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]],[[2.999,2.999],[4.001,2.999],[4.001,4.001],[2.999,4.001],[2.999,2.999]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[110.574]}},"distance/from-simple-polygon/to-polygon-inside":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3,3],[3,4],[4,4],[4,3],[3,3]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"distance/from-simple-polygon/to-polygon-outside":{"expression":["distance",{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3,3],[3,4],[4,4],[4,3],[3,3]]]},"properties":{}}],"inputs":[[{"zoom":20,"canonicalID":{"z":20,"x":3,"y":3}},{"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,2.999],[3,2.999],[3,0],[0,0]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[110.574]}},"distance/invalid-geometry":{"expression":["distance",{"type":"Nope!"}],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"'distance' expression requires valid geojson object that contains polygon geometry type."}]}}},"distance/missing-geometry":{"expression":["distance"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"'distance' expression requires exactly one argument, but found 0 instead."}]}}},"distance/multi-line":{"expression":["distance",{"type":"MultiLineString","coordinates":[[[3,3],[3,4]]]}],"inputs":[[{"zoom":16,"canonicalID":{"z":16,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[1,1]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[313783]}},"distance/multi-point":{"expression":["distance",{"type":"MultiPoint","coordinates":[[3,3],[3,4]]}],"inputs":[[{"zoom":16,"canonicalID":{"z":16,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[1,1]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[313783]}},"distance/multi-polygon":{"expression":["distance",{"type":"MultiPolygon","coordinates":[[[[3,3],[3,4],[4,4],[4,3],[3,3]]]]}],"inputs":[[{"zoom":16,"canonicalID":{"z":16,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[1,1]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[313783]}},"divide/basic":{"expression":["/",10,5],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[2]}},"downcase/basic":{"expression":["downcase","StRiNg"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["string"]}},"e/basic":{"expression":["e"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[2.71828]}},"e/unexpected-argument":{"expression":["e",2],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected arguments of type (), but found (number) instead."}]}}},"elevation/basic":{"propertySpec":{"type":"color","default":{"stops":[[0,"black"],[8849,"white"]]},"property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["interpolate",["linear"],["elevation"],0,"#000000",1,"#ff0000"],"inputs":[[{"elevation":0.5},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"color"},"outputs":[[0.5,0,0,1]]}},"equal/array":{"expression":["==",["get","x"],["literal",[1]]],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"\"==\" comparisons are not supported for type 'array'."}]}}},"equal/collator-value":{"expression":["==",["get","x"],["get","y"],["collator",{"case-sensitive":false,"diacritic-sensitive":false}]],"inputs":[[{},{"properties":{"x":"a","y":"A"}}],[{},{"properties":{"x":"1","y":"2"}}],[{},{"properties":{"x":"1","y":null}}],[{},{"properties":{"x":null,"y":null}}],[{},{"properties":{"x":1,"y":"1"}}],[{},{"properties":{"x":1,"y":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false,false,true,false,true]}},"equal/color":{"expression":["==",["get","x"],["to-color","red"]],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"\"==\" comparisons are not supported for type 'color'."}]}}},"equal/mismatch":{"expression":["==",["string",["get","x"]],["number",["get","y"]]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Cannot compare types 'string' and 'number'."}]}}},"equal/null-lhs":{"expression":["==",null,["get","x"]],"inputs":[[{},{"properties":{}}],[{},{"properties":{"x":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false]}},"equal/null-rhs":{"expression":["==",null,["get","x"]],"inputs":[[{},{"properties":{}}],[{},{"properties":{"x":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false]}},"equal/number":{"expression":["==",["number",["get","x"]],["get","y"]],"inputs":[[{},{"properties":{"x":1,"y":1}}],[{},{"properties":{"x":1,"y":2}}],[{},{"properties":{"x":1,"y":"1"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false,false]}},"equal/number-literal-lhs":{"expression":["==",1,["get","x"]],"inputs":[[{},{"properties":{"x":0}}],[{},{"properties":{"x":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true]}},"equal/object":{"expression":["==",["get","x"],["literal",{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"\"==\" comparisons are not supported for type 'object'."}]}}},"equal/string":{"expression":["==",["string",["get","x"]],["get","y"]],"inputs":[[{},{"properties":{"x":"1","y":"1"}}],[{},{"properties":{"x":"1","y":2}}],[{},{"properties":{"x":"1","y":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false,false]}},"equal/value":{"expression":["==",["get","x"],["get","y"]],"inputs":[[{},{"properties":{"x":0,"y":0}}],[{},{"properties":{"x":"0","y":"0"}}],[{},{"properties":{"x":0,"y":false}}],[{},{"properties":{"x":0,"y":"0"}}],[{},{"properties":{"x":0,"y":null}}],[{},{"properties":{"x":"0","y":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,false,false,false,false]}},"feature-state/basic":{"expression":["number",["feature-state","x"]],"inputs":[[{},{}],[{},{"featureState":{}}],[{},{"featureState":{"x":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property: Expected value to be of type number, but found null instead."},{"error":"layers[0].paint.some-property: Expected value to be of type number, but found null instead."},1]}},"feature-state/expression-arg":{"expression":["number",["feature-state",["at",1,["literal",["e","x","e"]]]]],"inputs":[[{},{"featureState":{"x":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[1]}},"floor/basic":{"expression":["floor",["get","x"]],"inputs":[[{},{"properties":{"x":-2.6}}],[{},{"properties":{"x":-2.5}}],[{},{"properties":{"x":-2.4}}],[{},{"properties":{"x":-2}}],[{},{"properties":{"x":2.6}}],[{},{"properties":{"x":2.5}}],[{},{"properties":{"x":2.4}}],[{},{"properties":{"x":2}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-3,-3,-3,-2,2,2,2,2]}},"format/basic":{"expression":["format","a",{},"b",{"font-scale":2},"c",{"text-font":["literal",["a","b"]]},"d",{"text-color":"rgb(0, 255, 0)"},"e",{"vertical-align":"center"}],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"formatted"},"outputs":[{"sections":[{"text":"a","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null},{"text":"b","image":null,"scale":2,"fontStack":null,"textColor":null,"verticalAlign":null},{"text":"c","image":null,"scale":null,"fontStack":"a,b","textColor":null,"verticalAlign":null},{"text":"d","image":null,"scale":null,"fontStack":null,"textColor":{"r":0,"g":1,"b":0,"a":1},"verticalAlign":null},{"text":"e","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":"center"}]}]}},"format/coercion":{"expression":["format",["get","a"],{},["get","1"],{},["get","true"],{}],"inputs":[[{},{"properties":{"1":1,"a":"a","true":true}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"formatted"},"outputs":[{"sections":[{"text":"a","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null},{"text":"1","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null},{"text":"true","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null}]}]}},"format/data-driven-font":{"expression":["format","a",{"font-scale":["get","font-scale"]}],"inputs":[[{},{"properties":{"font-scale":1.5}}],[{},{"properties":{"font-scale":0.5}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"formatted"},"outputs":[{"sections":[{"text":"a","image":null,"scale":1.5,"fontStack":null,"textColor":null,"verticalAlign":null}]},{"sections":[{"text":"a","image":null,"scale":0.5,"fontStack":null,"textColor":null,"verticalAlign":null}]}]}},"format/data-driven-scale":{"expression":["format","a",{"text-font":["array",["string",["get","text-font"]]]}],"inputs":[[{},{"properties":{"text-font":"test"}}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"Expected array but found array instead."}]}}},"format/data-driven-vertical-align":{"expression":["format","a",{"vertical-align":["get","vertical-align"]}],"inputs":[[{},{"properties":{"vertical-align":"center"}}],[{},{"properties":{"vertical-align":"top"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"formatted"},"outputs":[{"sections":[{"text":"a","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":"center"}]},{"sections":[{"text":"a","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":"top"}]}]}},"format/image-sections":{"expression":["format",["image","monument-15"],["image","beach-11"],{"vertical-align":"center"}],"inputs":[[{"availableImages":["monument-15"]},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"formatted"},"outputs":[{"sections":[{"text":"","image":{"name":"monument-15","available":true},"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null},{"text":"","image":{"name":"beach-11","available":false},"scale":null,"fontStack":null,"textColor":null,"verticalAlign":"center"}]}]}},"format/implicit":{"expression":["get","p"],"propertySpec":{"type":"formatted","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":"a"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"formatted"},"outputs":[{"sections":[{"text":"","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null}]},{"sections":[{"text":"0","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null}]},{"sections":[{"text":"a","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null}]}]}},"format/implicit-assert":{"expression":["number",["get","p"]],"propertySpec":{"type":"formatted","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected formatted but found number instead."}]}}},"format/implicit-coerce":{"expression":["to-string",["get","p"]],"propertySpec":{"type":"formatted","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":"a"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"formatted"},"outputs":[{"sections":[{"text":"","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null}]},{"sections":[{"text":"0","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null}]},{"sections":[{"text":"a","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null}]}]}},"format/implicit-omit":{"expression":["coalesce",["get","q"],["get","p"]],"propertySpec":{"type":"formatted","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":"a"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"formatted"},"outputs":[{"sections":[{"text":"","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null}]},{"sections":[{"text":"0","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null}]},{"sections":[{"text":"a","image":null,"scale":null,"fontStack":null,"textColor":null,"verticalAlign":null}]}]}},"format/invalid-text-font":{"expression":["format","a",{"text-font":["Helvetica","Arial"]}],"expected":{"compiled":{"result":"error","errors":[{"key":"[1][0]","error":"Unknown expression \"Helvetica\". If you wanted a literal array, use [\"literal\", [...]]."}]}}},"format/multiple-styles":{"expression":["format","a",{"font-scale":0.8,"text-color":"#fff"}],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"formatted"},"outputs":[{"sections":[{"text":"a","image":null,"scale":0.8,"fontStack":null,"textColor":{"r":1,"g":1,"b":1,"a":1},"verticalAlign":null}]}]}},"format/to-string":{"expression":["to-string",["format","a",{},"b",{"font-scale":2},"c",{"text-font":["literal",["a","b"]]}]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["abc"]}},"format/unknown-vertical-align":{"expression":["format","a",{"vertical-align":"unknown"}],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"'vertical-align' must be one of: 'bottom', 'center', 'top' but found 'unknown' instead."}]}}},"geometry-type/basic":{"expression":["geometry-type"],"inputs":[[{},{"geometry":{"type":"LineString","coordinates":[[0,0],[10,0]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["LineString"]}},"get/basic":{"expression":["number",["get","x"]],"inputs":[[{},{}],[{},{"properties":{"x":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property: Expected value to be of type number, but found null instead."},1]}},"get/from-literal":{"expression":["number",["get","x",["literal",{"x":0}]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"get/from-literal--missing":{"expression":["number",["get","x",["literal",{"y":0}]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected value to be of type number, but found null instead."}]}}},"get/from-object-property":{"expression":["number",["get","x",["object",["get","a"]]]],"inputs":[[{},{"properties":{"a":{"x":1}}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[1]}},"get/non-expression-arg":{"expression":["get","prop",{"prop":4}],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"Bare objects invalid. Use [\"literal\", {...}] instead."}]}}},"global-state/basic":{"expression":["number",["global-state","x"]],"inputs":[[{},{}]],"globalState":{"x":1},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[1]}},"global-state/empty":{"expression":["number",["global-state","x"]],"inputs":[[{},{}]],"globalState":{},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property: Expected value to be of type number, but found null instead."}]}},"global-state/false/empty":{"expression":["to-boolean",["global-state","x"]],"inputs":[[{},{}]],"globalState":{},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"global-state/false/empty-string":{"expression":["to-boolean",["global-state","x"]],"inputs":[[{},{}]],"globalState":{"x":""},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"global-state/false/false":{"expression":["to-boolean",["global-state","x"]],"inputs":[[{},{}]],"globalState":{"x":false},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"global-state/false/null":{"expression":["to-boolean",["global-state","x"]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"global-state/false/null-property":{"expression":["to-boolean",["global-state","x"]],"inputs":[[{},{}]],"globalState":{"x":null},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"global-state/false/zero":{"expression":["to-boolean",["global-state","x"]],"inputs":[[{},{}]],"globalState":{"x":0},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"global-state/invalid-property-arg":{"expression":["global-state",["concat","pr","op"]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Global state property must be string, but found object instead."}]}}},"global-state/missing-property-arg":{"expression":["global-state"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected 1 argument, but found 0 instead."}]}}},"global-state/null":{"expression":["number",["global-state","x"]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property: Expected value to be of type number, but found null instead."}]}},"global-state/projection":{"propertySpec":{"type":"projectionDefinition","property-type":"data-constant","expression":{"interpolated":true,"parameters":["zoom"]}},"expression":["global-state","projectionType"],"inputs":[[{},{}]],"globalState":{"projectionType":"vertical-perspective"},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"projectionDefinition"},"outputs":["vertical-perspective"]}},"global-state/projection-invalid-value":{"propertySpec":{"type":"projectionDefinition","property-type":"data-constant","expression":{"interpolated":true,"parameters":["zoom"]}},"expression":["global-state","projectionType"],"inputs":[[{},{}]],"globalState":{"projectionType":42},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"projectionDefinition"},"outputs":[{"error":"layers[0].paint.some-property: Could not parse projectionDefinition from value '42'"}]}},"global-state/true/number":{"expression":["to-boolean",["global-state","x"]],"inputs":[[{},{}]],"globalState":{"x":5},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[true]}},"global-state/true/string":{"expression":["to-boolean",["global-state","x"]],"inputs":[[{},{}]],"globalState":{"x":"some string"},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[true]}},"global-state/true/true":{"expression":["to-boolean",["global-state","x"]],"inputs":[[{},{}]],"globalState":{"x":true},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[true]}},"global-state/unexpected-second-arg":{"expression":["global-state","foo","bar"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected 1 argument, but found 2 instead."}]}}},"global-state/unset-with-other-keys":{"expression":["==",["global-state","x"],null],"inputs":[[{},{}]],"globalState":{"y":true},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[true]}},"greater/boolean":{"expression":[">",["boolean",["get","x"]],["boolean",["get","y"]]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"\">\" comparisons are not supported for type 'boolean'."}]}}},"greater/mismatch":{"expression":[">",["string",["get","x"]],["number",["get","y"]]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Cannot compare types 'string' and 'number'."}]}}},"greater/null":{"expression":[">",null,null],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"\">\" comparisons are not supported for type 'null'."}]}}},"greater/number":{"expression":[">",["number",["get","x"]],["number",["get","y"]]],"inputs":[[{},{"properties":{"x":1,"y":1}}],[{},{"properties":{"x":1,"y":2}}],[{},{"properties":{"x":2,"y":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,true]}},"greater/string":{"expression":[">",["string",["get","x"]],["string",["get","y"]]],"inputs":[[{},{"properties":{"x":"1","y":"1"}}],[{},{"properties":{"x":"1","y":"2"}}],[{},{"properties":{"x":"2","y":"1"}}],[{},{"properties":{"x":"abc","y":"azz"}}],[{},{"properties":{"x":"abc","y":"aaa"}}],[{},{"properties":{"x":"abc","y":"abc"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,true,false,true,false]}},"greater/string-and-value":{"expression":[">",["string",["get","x"]],["get","y"]],"inputs":[[{},{"properties":{"x":"2","y":"1"}}],[{},{"properties":{"x":"2","y":1}}],[{},{"properties":{"x":2,"y":"1"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,{"error":"layers[0].paint.some-property: Expected value to be of type string, but found number instead."},{"error":"layers[0].paint.some-property[1]: Expected value to be of type string, but found number instead."}]}},"greater/value":{"expression":[">",["get","x"],["get","y"]],"inputs":[[{},{"properties":{"x":"2","y":"10"}}],[{},{"properties":{"x":10,"y":1}}],[{},{"properties":{"x":"1","y":1}}],[{},{"properties":{"x":1,"y":"1"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,{"error":"layers[0].paint.some-property: Expected arguments for \">\" to be (string, string) or (number, number), but found (string, number) instead."},{"error":"layers[0].paint.some-property: Expected arguments for \">\" to be (string, string) or (number, number), but found (number, string) instead."}]}},"greater_or_equal/boolean":{"expression":[">=",["boolean",["get","x"]],["boolean",["get","y"]]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"\">=\" comparisons are not supported for type 'boolean'."}]}}},"greater_or_equal/mismatch":{"expression":[">=",["string",["get","x"]],["number",["get","y"]]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Cannot compare types 'string' and 'number'."}]}}},"greater_or_equal/null":{"expression":[">=",null,null],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"\">=\" comparisons are not supported for type 'null'."}]}}},"greater_or_equal/number":{"expression":[">=",["number",["get","x"]],["number",["get","y"]]],"inputs":[[{},{"properties":{"x":1,"y":1}}],[{},{"properties":{"x":1,"y":2}}],[{},{"properties":{"x":2,"y":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false,true]}},"greater_or_equal/string":{"expression":[">=",["string",["get","x"]],["string",["get","y"]]],"inputs":[[{},{"properties":{"x":"1","y":"1"}}],[{},{"properties":{"x":"1","y":"2"}}],[{},{"properties":{"x":"2","y":"1"}}],[{},{"properties":{"x":"abc","y":"azz"}}],[{},{"properties":{"x":"abc","y":"aaa"}}],[{},{"properties":{"x":"abc","y":"abc"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false,true,false,true,true]}},"greater_or_equal/string-and-value":{"expression":[">=",["string",["get","x"]],["get","y"]],"inputs":[[{},{"properties":{"x":"1","y":"1"}}],[{},{"properties":{"x":"1","y":1}}],[{},{"properties":{"x":1,"y":"1"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,{"error":"layers[0].paint.some-property: Expected value to be of type string, but found number instead."},{"error":"layers[0].paint.some-property[1]: Expected value to be of type string, but found number instead."}]}},"greater_or_equal/value":{"expression":[">=",["get","x"],["get","y"]],"inputs":[[{},{"properties":{"x":"2","y":"10"}}],[{},{"properties":{"x":10,"y":1}}],[{},{"properties":{"x":"1","y":1}}],[{},{"properties":{"x":1,"y":"1"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,{"error":"layers[0].paint.some-property: Expected arguments for \">=\" to be (string, string) or (number, number), but found (string, number) instead."},{"error":"layers[0].paint.some-property: Expected arguments for \">=\" to be (string, string) or (number, number), but found (number, string) instead."}]}},"has/basic":{"expression":["has","x"],"inputs":[[{},{}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":0}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,true,true,true]}},"has/object-arg/bare":{"expression":["has","prop",{"prop":4}],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"Bare objects invalid. Use [\"literal\", {...}] instead."}]}}},"has/object-arg/literal":{"expression":["has","prop",["literal",{"prop":true}]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[true]}},"heatmap-density/basic":{"propertySpec":{"type":"color","default":{"stops":[[0,"rgba(0, 0, 255, 0)"],[0.1,"royalblue"],[0.3,"cyan"],[0.5,"lime"],[0.7,"yellow"],[1,"red"]]},"property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["interpolate",["linear"],["heatmap-density"],0,"#000000",1,"#ff0000"],"inputs":[[{"heatmapDensity":0.5},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"color"},"outputs":[[0.5,0,0,1]]}},"id/basic":{"expression":["id"],"inputs":[[{},{}],[{},{"id":1}],[{},{"id":"one"}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":[null,1,"one"]}},"image/basic":{"expression":["image","monument-15"],"inputs":[[{"availableImages":["monument-15"]},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"resolvedImage"},"outputs":[{"name":"monument-15","available":true}]}},"image/coalesce":{"expression":["coalesce",["image","foo"],["image","bar"],["image","monument-15"]],"propertySpec":{"type":"resolvedImage","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{"availableImages":["monument-15"]},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"resolvedImage"},"outputs":[{"name":"monument-15","available":true}]}},"image/compound":{"expression":["image",["get","icon"]],"inputs":[[{"availableImages":["monument-15"]},{"properties":{"icon":"monument-15"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"resolvedImage"},"outputs":[{"name":"monument-15","available":true}]}},"image/implicit-assert":{"expression":["number",["get","icon"]],"propertySpec":{"type":"resolvedImage","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected resolvedImage but found number instead."}]}}},"image/invalid-image-name/boolean":{"expression":["image",true],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"Expected string but found boolean instead."},{"key":"","error":"No image name provided."}]}}},"image/invalid-image-name/non-string-expression":{"expression":["image",["+",1,4]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"Expected string but found number instead."},{"key":"","error":"No image name provided."}]}}},"image/invalid-image-name/null":{"expression":["image",null],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"Expected string but found null instead."},{"key":"","error":"No image name provided."}]}}},"image/invalid-image-name/number":{"expression":["image",123],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"Expected string but found number instead."},{"key":"","error":"No image name provided."}]}}},"in/assert-array":{"expression":["boolean",["in",["get","i"],["array",["get","arr"]]]],"inputs":[[{},{"properties":{"i":null,"arr":[9,8,7]}}],[{},{"properties":{"i":1,"arr":[9,8,7]}}],[{},{"properties":{"i":9,"arr":[9,8,7]}}],[{},{"properties":{"i":null,"arr":[9,8,7,null]}}],[{},{"properties":{"i":1,"arr":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,true,true,{"error":"layers[0].paint.some-property[1][2]: Expected value to be of type array, but found null instead."}]}},"in/assert-string":{"expression":["boolean",["in",["get","substr"],["string",["get","str"]]]],"inputs":[[{},{"properties":{"substr":null,"str":"helloworld"}}],[{},{"properties":{"substr":"foo","str":"helloworld"}}],[{},{"properties":{"substr":"low","str":"helloworld"}}],[{},{"properties":{"substr":"low","str":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,true,{"error":"layers[0].paint.some-property[1][2]: Expected value to be of type string, but found null instead."}]}},"in/basic-array":{"expression":["boolean",["in",["get","i"],["get","arr"]]],"inputs":[[{},{"properties":{"i":null,"arr":[9,8,7]}}],[{},{"properties":{"i":1,"arr":[9,8,7]}}],[{},{"properties":{"i":9,"arr":[9,8,7]}}],[{},{"properties":{"i":"foo","arr":["baz","bar","hello","foo","world"]}}],[{},{"properties":{"i":true,"arr":["foo",123,null,456,false,{},true]}}],[{},{"properties":{"i":null,"arr":["foo",123,null,456,false,{},true]}}],[{},{"properties":{"i":1,"arr":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,true,true,true,true,false]}},"in/basic-string":{"expression":["boolean",["in",["get","substr"],["get","str"]]],"inputs":[[{},{"properties":{"substr":null,"str":"helloworld"}}],[{},{"properties":{"substr":"foo","str":"helloworld"}}],[{},{"properties":{"substr":"low","str":"helloworld"}}],[{},{"properties":{"substr":true,"str":"falsetrue"}}],[{},{"properties":{"substr":false,"str":"falsetrue"}}],[{},{"properties":{"substr":123,"str":"hello123world"}}],[{},{"properties":{"substr":"low","str":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,true,true,true,true,false]}},"in/empty-array":{"expression":["in",["get","item"],["get","arr"]],"inputs":[[{},{"properties":{"item":1,"arr":[]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"in/empty-string":{"expression":["in",["get","substr"],["get","str"]],"inputs":[[{},{"properties":{"substr":"","str":""}}],[{},{"properties":{"substr":"","str":"helloworld"}}],[{},{"properties":{"substr":" ","str":""}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,false]}},"in/invalid-haystack":{"expression":["boolean",["in",["get","needle"],["get","haystack"]]],"inputs":[[{},{"properties":{"needle":1,"haystack":123}}],[{},{"properties":{"needle":"foo","haystack":{}}}],[{},{"properties":{"needle":"foo","haystack":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[{"error":"layers[0].paint.some-property[1]: Expected second argument to be of type array or string, but found number instead."},{"error":"layers[0].paint.some-property[1]: Expected second argument to be of type array or string, but found object instead."},false]}},"in/invalid-needle":{"expression":["boolean",["in",["get","needle"],["get","haystack"]]],"inputs":[[{},{"properties":{"needle":{},"haystack":[9,8,7]}}],[{},{"properties":{"needle":{},"haystack":"helloworld"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[{"error":"layers[0].paint.some-property[1]: Expected first argument to be of type boolean, string, number or null, but found object instead."},{"error":"layers[0].paint.some-property[1]: Expected first argument to be of type boolean, string, number or null, but found object instead."}]}},"in/invalid-needle-literal-array":{"expression":["in",["literal",["a"]],["literal",[["a"],["b"],["c"]]]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected first argument to be of type boolean, string, number or null, but found array instead"}]}}},"in/literal-array":{"expression":["in",2,["literal",[1,2,3]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[true]}},"in/literal-string":{"expression":["in","b","abc"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[true]}},"in/missing-haystack":{"expression":["in","a"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected 2 arguments, but found 1 instead."}]}}},"in/missing-needle":{"expression":["in"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected 2 arguments, but found 0 instead."}]}}},"in/unexpected-third-arg":{"expression":["in","a","abc",1],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected 2 arguments, but found 3 instead."}]}}},"index-of/array-with-null":{"expression":["index-of",["get","i"],["get","arr"]],"inputs":[[{},{"properties":{"i":null,"arr":["foo",123,null,456,false,{},true]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[2]}},"index-of/assert-array":{"expression":["index-of",["get","i"],["array",["get","arr"]]],"inputs":[[{},{"properties":{"i":null,"arr":[9,8,7]}}],[{},{"properties":{"i":null,"arr":[9,8,7,null]}}],[{},{"properties":{"i":1,"arr":[9,8,7]}}],[{},{"properties":{"i":9,"arr":[9,8,7,9]}}],[{},{"properties":{"i":1,"arr":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-1,3,-1,0,{"error":"layers[0].paint.some-property[2]: Expected value to be of type array, but found null instead."}]}},"index-of/assert-string":{"expression":["index-of",["get","substr"],["string",["get","str"]]],"inputs":[[{},{"properties":{"substr":null,"str":"helloworld"}}],[{},{"properties":{"substr":"foo","str":"helloworld"}}],[{},{"properties":{"substr":"low","str":"helloworld"}}],[{},{"properties":{"substr":"low","str":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-1,-1,3,{"error":"layers[0].paint.some-property[2]: Expected value to be of type string, but found null instead."}]}},"index-of/basic-array":{"expression":["index-of",["get","i"],["get","arr"]],"inputs":[[{},{"properties":{"i":null,"arr":[9,8,7]}}],[{},{"properties":{"i":1,"arr":[9,8,7]}}],[{},{"properties":{"i":9,"arr":[9,8,7]}}],[{},{"properties":{"i":"foo","arr":["baz","bar","hello","foo","world"]}}],[{},{"properties":{"i":true,"arr":["foo",123,null,456,false,{},true]}}],[{},{"properties":{"i":1,"arr":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-1,-1,0,3,6,{"error":"layers[0].paint.some-property: Expected second argument to be of type array or string, but found null instead."}]}},"index-of/basic-string":{"expression":["index-of",["get","substr"],["get","str"]],"inputs":[[{},{"properties":{"substr":null,"str":"helloworld"}}],[{},{"properties":{"substr":"foo","str":"helloworld"}}],[{},{"properties":{"substr":"low","str":"helloworld"}}],[{},{"properties":{"substr":true,"str":"falsetrue"}}],[{},{"properties":{"substr":false,"str":"falsetrue"}}],[{},{"properties":{"substr":123,"str":"hello123world"}}],[{},{"properties":{"substr":"low","str":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-1,-1,3,5,0,5,{"error":"layers[0].paint.some-property: Expected second argument to be of type array or string, but found null instead."}]}},"index-of/empty-array":{"expression":["index-of",["get","item"],["get","arr"]],"inputs":[[{},{"properties":{"item":1,"arr":[]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-1]}},"index-of/empty-string":{"expression":["index-of",["get","substr"],["get","str"]],"inputs":[[{},{"properties":{"substr":"","str":""}}],[{},{"properties":{"substr":"","str":"helloworld"}}],[{},{"properties":{"substr":" ","str":""}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0,0,-1]}},"index-of/invalid-haystack":{"expression":["index-of",["get","needle"],["get","haystack"]],"inputs":[[{},{"properties":{"needle":1,"haystack":123}}],[{},{"properties":{"needle":"foo","haystack":{}}}],[{},{"properties":{"needle":"foo","haystack":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property: Expected second argument to be of type array or string, but found number instead."},{"error":"layers[0].paint.some-property: Expected second argument to be of type array or string, but found object instead."},{"error":"layers[0].paint.some-property: Expected second argument to be of type array or string, but found null instead."}]}},"index-of/invalid-needle":{"expression":["index-of",["get","needle"],["get","haystack"]],"inputs":[[{},{"properties":{"needle":{},"haystack":[9,8,7]}}],[{},{"properties":{"needle":{},"haystack":"helloworld"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property: Expected first argument to be of type boolean, string, number or null, but found object instead."},{"error":"layers[0].paint.some-property: Expected first argument to be of type boolean, string, number or null, but found object instead."}]}},"index-of/invalid-needle-literal-array":{"expression":["index-of",["literal",["a"]],["literal",[["a"],["b"],["c"]]]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected first argument to be of type boolean, string, number or null, but found array instead"}]}}},"index-of/literal-array":{"expression":["index-of",2,["literal",[1,2,3]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[1]}},"index-of/literal-start-index":{"expression":["index-of",2,["literal",[0,1,2,3,0,1,2,3]],3],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[6]}},"index-of/literal-string":{"expression":["index-of","b","abc"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[1]}},"index-of/missing-haystack":{"expression":["index-of","a"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected 2 or 3 arguments, but found 1 instead."}]}}},"index-of/missing-needle":{"expression":["index-of"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected 2 or 3 arguments, but found 0 instead."}]}}},"index-of/multiple-match-array":{"expression":["index-of",["get","i"],["get","arr"]],"inputs":[[{},{"properties":{"i":1,"arr":[0,1,2,3,0,1,2,3]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[1]}},"index-of/multiple-match-string":{"expression":["index-of","b","abbc"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[1]}},"index-of/surrogate-pair":{"expression":["index-of",["get","substr"],["get","str"]],"inputs":[[{},{"properties":{"substr":"\u9547","str":"\u5e02\u9547"}}],[{},{"properties":{"substr":"\u5e02\u9547","str":"\u4e10\ud85a\ude2d\u5e02\u9547"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[1,2]}},"index-of/unexpected-fourth-arg":{"expression":["index-of","a","abc",1,8],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected 2 or 3 arguments, but found 4 instead."}]}}},"index-of/with-from-index":{"expression":["index-of",["get","needle"],["get","hay"],["get","i"]],"inputs":[[{},{"properties":{"needle":null,"hay":"helloworld","i":0}}],[{},{"properties":{"needle":"foo","hay":"helloworld","i":0}}],[{},{"properties":{"needle":"low","hay":"helloworldlow","i":4}}],[{},{"properties":{"needle":true,"hay":"falsetruetrue","i":6}}],[{},{"properties":{"needle":false,"hay":"falsetrue","i":0}}],[{},{"properties":{"needle":123,"hay":"hello123world","i":6}}],[{},{"properties":{"needle":"low","hay":null,"i":0}}],[{},{"properties":{"needle":7,"hay":[9,8,7,8,7,7],"i":3}}],[{},{"properties":{"needle":9,"hay":[9,8,7,8,7,7],"i":1}}],[{},{"properties":{"needle":8,"hay":[9,8,7,8,7,7],"i":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-1,-1,10,9,0,-1,{"error":"layers[0].paint.some-property: Expected second argument to be of type array or string, but found null instead."},4,-1,1]}},"index-of/with-negative-from-index":{"expression":["index-of",["get","needle"],["get","hay"],["get","i"]],"inputs":[[{},{"properties":{"needle":"c","hay":"abcabc","i":-1}}],[{},{"properties":{"needle":2,"hay":[0,1,2,0,1,2],"i":-1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[2,5]}},"interpolate-hcl/linear-color":{"expression":["interpolate-hcl",["linear"],["get","x"],1,"red",11,["get","color"]],"inputs":[[{},{"properties":{"x":0,"color":"blue"}}],[{},{"properties":{"x":5,"color":"blue"}}],[{},{"properties":{"x":5,"color":"#0000ff00"}}],[{},{"properties":{"x":5,"color":"rgb(0% 0% 100% / 0)"}}],[{},{"properties":{"x":5,"color":"hsl(240 100% 50% / 0)"}}],[{},{"properties":{"x":11,"color":"blue"}}],[{},{"properties":{"x":11,"color":"oops blue"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[1,0,0,1],[1,0,0.425112,1],[0.6,0,0.255067,0.6],[0.6,0,0.255067,0.6],[0.6,0,0.255067,0.6],[0,0,1,1],{"error":"layers[0].paint.some-property[6]: Could not parse color from value 'oops blue'"}]}},"interpolate-hcl/linear-color-array":{"propertySpec":{"type":"colorArray","property-type":"data-driven","expression":{"interpolated":true,"parameters":["feature"]}},"expression":["interpolate-hcl",["linear"],["get","x"],0,["literal",["white","black"]],10,["get","colors"]],"inputs":[[{},{"properties":{"x":0,"colors":["black","white"]}}],[{},{"properties":{"x":5,"colors":["black","white"]}}],[{},{"properties":{"x":10,"colors":["black","white"]}}],[{},{"properties":{"x":-1234,"colors":["black","white"]}}],[{},{"properties":{"x":1234,"colors":["black","white"]}}],[{},{"properties":{"x":"abcd","colors":["black","white"]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"colorArray"},"outputs":[{"values":[{"r":1,"g":1,"b":1,"a":1},{"r":0,"g":0,"b":0,"a":1}]},{"values":[{"r":0.466326,"g":0.466326,"b":0.466326,"a":1},{"r":0.466326,"g":0.466326,"b":0.466326,"a":1}]},{"values":[{"r":0,"g":0,"b":0,"a":1},{"r":1,"g":1,"b":1,"a":1}]},{"values":[{"r":1,"g":1,"b":1,"a":1},{"r":0,"g":0,"b":0,"a":1}]},{"values":[{"r":0,"g":0,"b":0,"a":1},{"r":1,"g":1,"b":1,"a":1}]},{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."}]}},"interpolate-hcl/uninterpolable-output/bare-color-array":{"expression":["interpolate-hcl",["linear"],["zoom"],0,["#f00","#0f0","#00f"],1,["#f99","#9f9","#99f"]],"expected":{"compiled":{"result":"error","errors":[{"key":"[4][0]","error":"Unknown expression \"#f00\". If you wanted a literal array, use [\"literal\", [...]]."}]}}},"interpolate-hcl/uninterpolable-output/bare-number-array":{"expression":["interpolate-hcl",["linear"],["zoom"],0,[10,20,30],1,[20,30,40]],"expected":{"compiled":{"result":"error","errors":[{"key":"[4][0]","error":"Expression name must be a string, but found number instead. If you wanted a literal array, use [\"literal\", [...]]."}]}}},"interpolate-hcl/uninterpolable-output/bare-object":{"expression":["interpolate-hcl",["linear"],["zoom"],0,{"prop":"foo"},1,{"prop":"bar"}],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Bare objects invalid. Use [\"literal\", {...}] instead."}]}}},"interpolate-hcl/uninterpolable-output/boolean":{"propertySpec":{"type":"boolean","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate-hcl",["linear"],["zoom"],0,false,1,true],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Expected color but found boolean instead."}]}}},"interpolate-hcl/uninterpolable-output/null":{"propertySpec":{"type":"null","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate-hcl",["linear"],["zoom"],0,null,1,null],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Expected color but found null instead."}]}}},"interpolate-hcl/uninterpolable-output/number":{"expression":["interpolate-hcl",["linear"],["get","x"],0,100,10,["get","b"]],"inputs":[[{},{"properties":{"x":0,"b":200}}],[{},{"properties":{"x":5,"b":200}}],[{},{"properties":{"x":10,"b":200}}],[{},{"properties":{"x":-1234,"b":200}}],[{},{"properties":{"x":1234,"b":200}}],[{},{"properties":{"x":"abcd","b":200}}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Expected color but found number instead."}]}}},"interpolate-hcl/uninterpolable-output/number-array":{"propertySpec":{"type":"numberArray","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate-hcl",["linear"],["get","x"],0,["literal",[0,0]],10,["get","b"]],"inputs":[[{},{"properties":{"x":0,"b":[10,20]}}],[{},{"properties":{"x":5,"b":[10,20]}}],[{},{"properties":{"x":10,"b":[10,20]}}],[{},{"properties":{"x":-1234,"b":[10,20]}}],[{},{"properties":{"x":1234,"b":[10,20]}}],[{},{"properties":{"x":"abcd","b":[10,20]}}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Expected color but found array instead."}]}}},"interpolate-hcl/uninterpolable-output/projection":{"propertySpec":{"type":"projectionDefinition","property-type":"data-constant","expression":{"interpolated":true,"parameters":["zoom"]}},"expression":["interpolate-hcl",["linear"],["zoom"],0,"vertical-perspective",10,"mercator"],"inputs":[[{"zoom":5},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Could not parse color from value 'vertical-perspective'"}]}}},"interpolate-hcl/uninterpolable-output/string":{"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate-hcl",["linear"],["zoom"],0,"reddish",2,"greenish"],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Could not parse color from value 'reddish'"}]}}},"interpolate-lab/linear-color":{"expression":["interpolate-lab",["linear"],["get","x"],1,"red",11,["get","color"]],"inputs":[[{},{"properties":{"x":0,"color":"blue"}}],[{},{"properties":{"x":5,"color":"blue"}}],[{},{"properties":{"x":5,"color":"#0000ff00"}}],[{},{"properties":{"x":5,"color":"rgb(0% 0% 100% / 0)"}}],[{},{"properties":{"x":5,"color":"hsl(240 100% 50% / 0)"}}],[{},{"properties":{"x":11,"color":"blue"}}],[{},{"properties":{"x":11,"color":"oops blue"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[1,0,0,1],[0.814095,0,0.444772,1],[0.488457,0,0.266863,0.6],[0.488457,0,0.266863,0.6],[0.488457,0,0.266863,0.6],[0,0,1,1],{"error":"layers[0].paint.some-property[6]: Could not parse color from value 'oops blue'"}]}},"interpolate-lab/linear-color-array":{"propertySpec":{"type":"colorArray","property-type":"data-driven","expression":{"interpolated":true,"parameters":["feature"]}},"expression":["interpolate-lab",["linear"],["get","x"],0,["literal",["white","black"]],10,["get","colors"]],"inputs":[[{},{"properties":{"x":0,"colors":["black","white"]}}],[{},{"properties":{"x":5,"colors":["black","white"]}}],[{},{"properties":{"x":10,"colors":["black","white"]}}],[{},{"properties":{"x":-1234,"colors":["black","white"]}}],[{},{"properties":{"x":1234,"colors":["black","white"]}}],[{},{"properties":{"x":"abcd","colors":["black","white"]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"colorArray"},"outputs":[{"values":[{"r":1,"g":1,"b":1,"a":1},{"r":0,"g":0,"b":0,"a":1}]},{"values":[{"r":0.466326,"g":0.466326,"b":0.466326,"a":1},{"r":0.466326,"g":0.466326,"b":0.466326,"a":1}]},{"values":[{"r":0,"g":0,"b":0,"a":1},{"r":1,"g":1,"b":1,"a":1}]},{"values":[{"r":1,"g":1,"b":1,"a":1},{"r":0,"g":0,"b":0,"a":1}]},{"values":[{"r":0,"g":0,"b":0,"a":1},{"r":1,"g":1,"b":1,"a":1}]},{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."}]}},"interpolate-lab/uninterpolable-output/bare-color-array":{"expression":["interpolate-lab",["linear"],["zoom"],0,["#f00","#0f0","#00f"],1,["#f99","#9f9","#99f"]],"expected":{"compiled":{"result":"error","errors":[{"key":"[4][0]","error":"Unknown expression \"#f00\". If you wanted a literal array, use [\"literal\", [...]]."}]}}},"interpolate-lab/uninterpolable-output/bare-number-array":{"expression":["interpolate-lab",["linear"],["zoom"],0,[10,20,30],1,[20,30,40]],"expected":{"compiled":{"result":"error","errors":[{"key":"[4][0]","error":"Expression name must be a string, but found number instead. If you wanted a literal array, use [\"literal\", [...]]."}]}}},"interpolate-lab/uninterpolable-output/bare-object":{"expression":["interpolate-lab",["linear"],["zoom"],0,{"prop":"foo"},1,{"prop":"bar"}],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Bare objects invalid. Use [\"literal\", {...}] instead."}]}}},"interpolate-lab/uninterpolable-output/boolean":{"propertySpec":{"type":"boolean","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate-lab",["linear"],["zoom"],0,false,1,true],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Expected color but found boolean instead."}]}}},"interpolate-lab/uninterpolable-output/null":{"propertySpec":{"type":"null","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate-lab",["linear"],["zoom"],0,null,1,null],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Expected color but found null instead."}]}}},"interpolate-lab/uninterpolable-output/number":{"expression":["interpolate-lab",["linear"],["get","x"],0,100,10,["get","b"]],"inputs":[[{},{"properties":{"x":0,"b":200}}],[{},{"properties":{"x":5,"b":200}}],[{},{"properties":{"x":10,"b":200}}],[{},{"properties":{"x":-1234,"b":200}}],[{},{"properties":{"x":1234,"b":200}}],[{},{"properties":{"x":"abcd","b":200}}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Expected color but found number instead."}]}}},"interpolate-lab/uninterpolable-output/number-array":{"propertySpec":{"type":"numberArray","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate-lab",["linear"],["get","x"],0,["literal",[0,0]],10,["get","b"]],"inputs":[[{},{"properties":{"x":0,"b":[10,20]}}],[{},{"properties":{"x":5,"b":[10,20]}}],[{},{"properties":{"x":10,"b":[10,20]}}],[{},{"properties":{"x":-1234,"b":[10,20]}}],[{},{"properties":{"x":1234,"b":[10,20]}}],[{},{"properties":{"x":"abcd","b":[10,20]}}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Expected color but found array instead."}]}}},"interpolate-lab/uninterpolable-output/projection":{"propertySpec":{"type":"projectionDefinition","property-type":"data-constant","expression":{"interpolated":true,"parameters":["zoom"]}},"expression":["interpolate-lab",["linear"],["zoom"],0,"vertical-perspective",10,"mercator"],"inputs":[[{"zoom":5},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Could not parse color from value 'vertical-perspective'"}]}}},"interpolate-lab/uninterpolable-output/string":{"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate-lab",["linear"],["zoom"],0,"reddish",2,"greenish"],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Could not parse color from value 'reddish'"}]}}},"interpolate/cubic-bezier":{"expression":["number",["interpolate",["cubic-bezier",0.42,0,0.58,1],["number",["get","x"]],0,0,100,100]],"inputs":[[{},{"properties":{"x":0}}],[{},{"properties":{"x":10}}],[{},{"properties":{"x":20}}],[{},{"properties":{"x":30}}],[{},{"properties":{"x":40}}],[{},{"properties":{"x":50}}],[{},{"properties":{"x":60}}],[{},{"properties":{"x":70}}],[{},{"properties":{"x":80}}],[{},{"properties":{"x":90}}],[{},{"properties":{"x":100}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[0,1.97224,8.16597,18.7395,33.1883,50,66.8116,81.2604,91.834,98.0277,100]}},"interpolate/cubic-bezier-3-args":{"expression":["number",["interpolate",["cubic-bezier",0,0,1],["number",["get","x"]],0,0,100,100]],"inputs":[],"expected":{"compiled":{"result":"error","errors":[{"key":"[1][1]","error":"Cubic bezier interpolation requires four numeric arguments with values between 0 and 1."}]}}},"interpolate/cubic-bezier-5-args":{"expression":["number",["interpolate",["cubic-bezier",0,0,1,1,1],["number",["get","x"]],0,0,100,100]],"inputs":[],"expected":{"compiled":{"result":"error","errors":[{"key":"[1][1]","error":"Cubic bezier interpolation requires four numeric arguments with values between 0 and 1."}]}}},"interpolate/cubic-bezier-expression-control-point":{"expression":["number",["interpolate",["cubic-bezier",0,["get","control-point-2"],1,1],["number",["get","x"]],0,0,100,100]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1][1]","error":"Cubic bezier interpolation requires four numeric arguments with values between 0 and 1."}]}}},"interpolate/cubic-bezier-invalid-control-point":{"expression":["number",["interpolate",["cubic-bezier",0,1.75,1,1],["number",["get","x"]],0,0,100,100]],"inputs":[],"expected":{"compiled":{"result":"error","errors":[{"key":"[1][1]","error":"Cubic bezier interpolation requires four numeric arguments with values between 0 and 1."}]}}},"interpolate/duplicate_stops":{"expression":["interpolate",["linear"],0,0,1,0,2],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[5]","error":"Input/output pairs for \"interpolate\" expressions must be arranged with input values in strictly ascending order."}]}}},"interpolate/exponential":{"expression":["number",["interpolate",["exponential",2],["number",["get","x"]],1,2,3,6]],"inputs":[[{},{"properties":{"x":0}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":2}}],[{},{"properties":{"x":3}}],[{},{"properties":{"x":4}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[2,2,3.33333,6,6]}},"interpolate/exponential-2-args":{"expression":["number",["interpolate",["exponential",2,9999],["number",["get","x"]],1,2,3,6]],"inputs":[[{},{"properties":{"x":0}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":2}}],[{},{"properties":{"x":3}}],[{},{"properties":{"x":4}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[2,2,3.33333,6,6]}},"interpolate/exponential-color":{"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate",["exponential",2],["get","x"],1,"red",11,["get","color"]],"inputs":[[{},{"properties":{"x":0,"color":"blue"}}],[{},{"properties":{"x":5,"color":"blue"}}],[{},{"properties":{"x":5,"color":"#0000ff00"}}],[{},{"properties":{"x":5,"color":"rgb(0% 0% 100% / 0)"}}],[{},{"properties":{"x":5,"color":"hsl(240 100% 50% / 0)"}}],[{},{"properties":{"x":11,"color":"blue"}}],[{},{"properties":{"x":11,"color":"oops blue"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[1,0,0,1],[0.985337,0,0.0146627,1],[0.970889,0,0.0144477,0.985337],[0.970889,0,0.0144477,0.985337],[0.970889,0,0.0144477,0.985337],[0,0,1,1],{"error":"layers[0].paint.some-property[6]: Could not parse color from value 'oops blue'"}]}},"interpolate/exponential-expression-base":{"expression":["interpolate",["exponential",["get","exponent-base"]],["number",["get","x"]],1,2,3,6],"expected":{"compiled":{"result":"error","errors":[{"key":"[1][1]","error":"Exponential interpolation requires a numeric base."}]}}},"interpolate/exponential-number-array":{"expression":["interpolate",["exponential",2],["number",["get","x"]],1,["literal",[2,1]],3,["literal",[6,1]]],"inputs":[[{},{"properties":{"x":0}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":2}}],[{},{"properties":{"x":3}}],[{},{"properties":{"x":4}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[[2,1],[2,1],[3.33333,1],[6,1],[6,1]]}},"interpolate/exponential-single-stop":{"expression":["number",["interpolate",["exponential",2],["number",["get","x"]],1,2]],"inputs":[[{},{"properties":{"x":0}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":2}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[2,2,2]}},"interpolate/exponential-string-array":{"expression":["interpolate",["exponential",2],["number",["get","x"]],1,["literal",["a"]],3,["literal",["b"]]],"inputs":[],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Type array is not interpolatable."}]}}},"interpolate/exponential-uninterpolatable-numeric-array":{"expression":["interpolate",["exponential",2],["number",["get","x"]],1,["array","number",["get","array"]],3,["array","number",["get","array_two"]]],"inputs":[],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Type array is not interpolatable."}]}}},"interpolate/global-state/basic":{"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate",["linear"],["zoom"],10,["global-state","x"],20,50],"globalState":{"x":5},"inputs":[[{"zoom":10},{}],[{"zoom":20},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"number"},"outputs":[5,50]}},"interpolate/global-state/missing-property":{"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate",["linear"],["zoom"],10,["global-state","x"],20,50],"globalState":{},"inputs":[[{"zoom":10},{}],[{"zoom":20},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property[4]: Expected value to be of type number, but found null instead."},50]}},"interpolate/infer-array-type":{"propertySpec":{"type":"array","value":"string","property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["step",["number",["get","x"]],["literal",["one"]],10,["literal",["one","two"]]],"inputs":[],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[]}},"interpolate/linear":{"propertySpec":{"type":"number","property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["interpolate",["linear"],["get","x"],0,100,10,200],"inputs":[[{},{"properties":{"x":0}}],[{},{"properties":{"x":5}}],[{},{"properties":{"x":10}}],[{},{"properties":{"x":-1234}}],[{},{"properties":{"x":1234}}],[{},{"properties":{"x":"abcd"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[100,150,200,100,200,{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."}]}},"interpolate/linear-1-arg":{"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":true,"parameters":["feature"]}},"expression":["interpolate",["linear",0.5],["get","x"],0,100,10,200],"inputs":[[{},{"properties":{"x":0}}],[{},{"properties":{"x":5}}],[{},{"properties":{"x":10}}],[{},{"properties":{"x":-1234}}],[{},{"properties":{"x":1234}}],[{},{"properties":{"x":"abcd"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[100,150,200,100,200,{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."}]}},"interpolate/linear-color":{"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":true,"parameters":["feature"]}},"expression":["interpolate",["linear"],["get","x"],1,"red",11,["get","color"]],"inputs":[[{},{"properties":{"x":0,"color":"blue"}}],[{},{"properties":{"x":5,"color":"blue"}}],[{},{"properties":{"x":5,"color":"#0000ff00"}}],[{},{"properties":{"x":5,"color":"rgb(0% 0% 100% / 0)"}}],[{},{"properties":{"x":5,"color":"hsl(240 100% 50% / 0)"}}],[{},{"properties":{"x":11,"color":"blue"}}],[{},{"properties":{"x":11,"color":"oops blue"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[1,0,0,1],[0.6,0,0.4,1],[0.36,0,0.24,0.6],[0.36,0,0.24,0.6],[0.36,0,0.24,0.6],[0,0,1,1],{"error":"layers[0].paint.some-property[6]: Could not parse color from value 'oops blue'"}]}},"interpolate/linear-color-array":{"propertySpec":{"type":"colorArray","property-type":"data-driven","expression":{"interpolated":true,"parameters":["feature"]}},"expression":["interpolate",["linear"],["get","x"],0,["literal",["white","black"]],10,["get","colors"]],"inputs":[[{},{"properties":{"x":0,"colors":["black","white"]}}],[{},{"properties":{"x":5,"colors":["black","white"]}}],[{},{"properties":{"x":10,"colors":["black","white"]}}],[{},{"properties":{"x":-1234,"colors":["black","white"]}}],[{},{"properties":{"x":1234,"colors":["black","white"]}}],[{},{"properties":{"x":"abcd","colors":["black","white"]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"colorArray"},"outputs":[{"values":[{"r":1,"g":1,"b":1,"a":1},{"r":0,"g":0,"b":0,"a":1}]},{"values":[{"r":0.5,"g":0.5,"b":0.5,"a":1},{"r":0.5,"g":0.5,"b":0.5,"a":1}]},{"values":[{"r":0,"g":0,"b":0,"a":1},{"r":1,"g":1,"b":1,"a":1}]},{"values":[{"r":1,"g":1,"b":1,"a":1},{"r":0,"g":0,"b":0,"a":1}]},{"values":[{"r":0,"g":0,"b":0,"a":1},{"r":1,"g":1,"b":1,"a":1}]},{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."}]}},"interpolate/linear-many-stops":{"expression":["number",["interpolate",["exponential",1],["number",["get","x"]],2,100,55,200,132,300,607,400,1287,500,1985,600,2650,700,3299,800,3995,900,4927,1000,7147,10000,10028,100000,12889,1000000,40000,10000000]],"inputs":[[{},{"properties":{"x":2}}],[{},{"properties":{"x":20}}],[{},{"properties":{"x":607}}],[{},{"properties":{"x":680}}],[{},{"properties":{"x":4927}}],[{},{"properties":{"x":7300}}],[{},{"properties":{"x":10000}}],[{},{"properties":{"x":20000}}],[{},{"properties":{"x":40000}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[100,133.962,400,410.735,1000,14779.5,99125.3,3360628,10000000]}},"interpolate/linear-number":{"expression":["interpolate",["linear"],["get","x"],0,100,10,["get","b"]],"inputs":[[{},{"properties":{"x":0,"b":200}}],[{},{"properties":{"x":5,"b":200}}],[{},{"properties":{"x":10,"b":200}}],[{},{"properties":{"x":-1234,"b":200}}],[{},{"properties":{"x":1234,"b":200}}],[{},{"properties":{"x":"abcd","b":200}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[100,150,200,100,200,{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."}]}},"interpolate/linear-number-array":{"propertySpec":{"type":"numberArray","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate",["linear"],["get","x"],0,["literal",[0,0]],10,["get","b"]],"inputs":[[{},{"properties":{"x":0,"b":[10,20]}}],[{},{"properties":{"x":5,"b":[10,20]}}],[{},{"properties":{"x":10,"b":[10,20]}}],[{},{"properties":{"x":-1234,"b":[10,20]}}],[{},{"properties":{"x":1234,"b":[10,20]}}],[{},{"properties":{"x":"abcd","b":[10,20]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"numberArray"},"outputs":[{"values":[0,0]},{"values":[5,10]},{"values":[10,20]},{"values":[0,0]},{"values":[10,20]},{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."}]}},"interpolate/projection/global-state":{"propertySpec":{"type":"projectionDefinition","property-type":"data-constant","expression":{"interpolated":true,"parameters":["zoom"]}},"expression":["interpolate",["linear"],["zoom"],0,["global-state","from"],10,["global-state","to"]],"inputs":[[{"zoom":0},{}],[{"zoom":5},{}],[{"zoom":10},{}]],"globalState":{"from":"vertical-perspective","to":"mercator"},"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"projectionDefinition"},"outputs":["vertical-perspective",{"from":"vertical-perspective","to":"mercator","transition":0.5},"mercator"]}},"interpolate/projection/higher-than-stop":{"propertySpec":{"type":"projectionDefinition","property-type":"data-constant","expression":{"interpolated":true,"parameters":["zoom"]}},"expression":["interpolate",["linear"],["zoom"],0,"vertical-perspective",4,"mercator"],"inputs":[[{"zoom":5},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"projectionDefinition"},"outputs":["mercator"]}},"interpolate/projection/linear":{"propertySpec":{"type":"projectionDefinition","property-type":"data-constant","expression":{"interpolated":true,"parameters":["zoom"]}},"expression":["interpolate",["linear"],["zoom"],0,"vertical-perspective",10,"mercator"],"inputs":[[{"zoom":5},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"projectionDefinition"},"outputs":[{"from":"vertical-perspective","to":"mercator","transition":0.5}]}},"interpolate/projection/lower-than-stop":{"propertySpec":{"type":"projectionDefinition","property-type":"data-constant","expression":{"interpolated":true,"parameters":["zoom"]}},"expression":["interpolate",["linear"],["zoom"],8,"vertical-perspective",10,"mercator"],"inputs":[[{"zoom":5},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"projectionDefinition"},"outputs":["vertical-perspective"]}},"interpolate/projection/same-from-to":{"propertySpec":{"type":"projectionDefinition","property-type":"data-constant","expression":{"interpolated":true,"parameters":["zoom"]}},"expression":["interpolate",["linear"],["zoom"],0,"mercator",10,"mercator"],"inputs":[[{"zoom":5},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"projectionDefinition"},"outputs":[{"from":"mercator","to":"mercator","transition":0.5}]}},"interpolate/uninterpolable-output/bare-color-array":{"expression":["interpolate",["linear"],["zoom"],0,["#f00","#0f0","#00f"],1,["#f99","#9f9","#99f"]],"expected":{"compiled":{"result":"error","errors":[{"key":"[4][0]","error":"Unknown expression \"#f00\". If you wanted a literal array, use [\"literal\", [...]]."}]}}},"interpolate/uninterpolable-output/bare-number-array":{"expression":["interpolate",["linear"],["zoom"],0,[10,20,30],1,[20,30,40]],"expected":{"compiled":{"result":"error","errors":[{"key":"[4][0]","error":"Expression name must be a string, but found number instead. If you wanted a literal array, use [\"literal\", [...]]."}]}}},"interpolate/uninterpolable-output/bare-object":{"expression":["interpolate",["linear"],["zoom"],0,{"prop":"foo"},1,{"prop":"bar"}],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Bare objects invalid. Use [\"literal\", {...}] instead."}]}}},"interpolate/uninterpolable-output/boolean":{"propertySpec":{"type":"boolean","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate",["linear"],["zoom"],0,false,1,true],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Type boolean is not interpolatable."}]}}},"interpolate/uninterpolable-output/null":{"propertySpec":{"type":"null","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate",["linear"],["zoom"],0,null,1,null],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Type null is not interpolatable."}]}}},"interpolate/uninterpolable-output/string":{"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"expression":["interpolate",["linear"],["zoom"],0,"reddish",2,"greenish"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Type string is not interpolatable."}]}}},"is-supported-script/default":{"expression":["is-supported-script",["get","x"]],"description":"'is-supported-script' always returns true when run without global context providing an 'isSupportedScript' function (as in the expression tests). The 'is-supported-script' render tests provide the necessary global context and exercise the functionality.","inputs":[[{},{"properties":{"x":"\u05e9\u05b8\u05c1\u05dc\u05d5\u05b9\u05dd"}}],[{},{"properties":{"x":"\u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true]}},"join/empty-array":{"expression":["join",["literal",[]],","],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":[""]}},"join/empty-item":{"expression":["join",["literal",["","latitude","","","longitude",""]],","],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":[",latitude,,,longitude,"]}},"join/empty-separator":{"expression":["join",["literal",["latitude","longitude"]],""],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["latitudelongitude"]}},"join/non-array":{"expression":["join","1+2+3","+"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"Expected array but found string instead."}]}}},"join/numbers":{"expression":["join",["literal",[1,2,3]],"+"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"Expected array but found array instead."}]}}},"join/property":{"expression":["join",["get","haystack"],"+"],"inputs":[[{},{"properties":{"haystack":["1","2","3"]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["1+2+3"]}},"legacy/categorical/array":{"expression":{"type":"categorical","property":"p","stops":[[0,[0]],[1,[1]]]},"propertySpec":{"type":"array","value":"number","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]},"default":[-1]},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[[-1],[0],[1],[-1],[-1]]}},"legacy/categorical/array-default":{"expression":{"type":"categorical","property":"p","stops":[[0,[0]],[1,[1]]],"default":[-1]},"propertySpec":{"type":"array","value":"number","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[[-1],[0],[1],[-1],[-1]]}},"legacy/categorical/boolean":{"expression":{"type":"categorical","property":"p","stops":[[true,true],[false,false]]},"propertySpec":{"type":"boolean","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]},"default":true},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":true}}],[{},{"properties":{"p":false}}],[{},{"properties":{"p":"wrong type"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,false,true]}},"legacy/categorical/boolean-default":{"expression":{"type":"categorical","property":"p","stops":[[true,true],[false,false]],"default":true},"propertySpec":{"type":"boolean","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":true}}],[{},{"properties":{"p":false}}],[{},{"properties":{"p":"wrong type"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,false,true]}},"legacy/categorical/color":{"expression":{"type":"categorical","property":"p","stops":[["0","red"],["1","lime"]]},"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]},"default":"blue"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":"0"}}],[{},{"properties":{"p":"1"}}],[{},{"properties":{"p":0}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[0,0,1,1],[1,0,0,1],[0,1,0,1],[0,0,1,1]]}},"legacy/categorical/color-default":{"expression":{"type":"categorical","property":"p","stops":[["0","red"],["1","lime"]],"default":"blue"},"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":"0"}}],[{},{"properties":{"p":"1"}}],[{},{"properties":{"p":0}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[0,0,1,1],[1,0,0,1],[0,1,0,1],[0,0,1,1]]}},"legacy/categorical/number":{"expression":{"type":"categorical","property":"p","stops":[[0,0],[1,1]]},"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]},"default":-1},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-1,-1,0,1,-1,-1]}},"legacy/categorical/number-default":{"expression":{"type":"categorical","property":"p","stops":[[0,0],[1,1]],"default":-1},"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-1,-1,0,1,-1,-1]}},"legacy/categorical/string":{"expression":{"type":"categorical","property":"p","stops":[["0","0"],["1","1"]]},"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]},"default":"-1"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":"0"}}],[{},{"properties":{"p":"1"}}],[{},{"properties":{"p":0}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["-1","0","1","-1"]}},"legacy/categorical/string-default":{"expression":{"type":"categorical","property":"p","stops":[["0","0"],["1","1"]],"default":"-1"},"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":"0"}}],[{},{"properties":{"p":"1"}}],[{},{"properties":{"p":0}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["-1","0","1","-1"]}},"legacy/categorical/tokens":{"expression":{"type":"categorical","property":"p","stops":[["0","0 {a}"],["1","1 {b}"]],"default":"default"},"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]},"tokens":true},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":"0","a":"a"}}],[{},{"properties":{"p":"1","b":"b"}}],[{},{"properties":{"p":0}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["default","0 {a}","1 {b}","default"]}},"legacy/exponential/array":{"expression":{"type":"exponential","property":"p","stops":[[0,[0]],[1,[1]]]},"propertySpec":{"type":"array","value":"number","length":1,"property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found null instead."},[0],[0],[0.5],[1],[1],{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."}]}},"legacy/exponential/array-default":{"expression":{"type":"exponential","property":"p","stops":[[0,[0]],[1,[1]]],"default":[-1]},"propertySpec":{"type":"array","value":"number","length":1,"property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[[-1],[0],[0],[0.5],[1],[1],[-1]]}},"legacy/exponential/base":{"expression":{"type":"exponential","base":0.5,"stops":[[0,0],[1,1]]},"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{"zoom":0.5},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"number"},"outputs":[0.585786]}},"legacy/exponential/color":{"expression":{"type":"exponential","property":"p","stops":[[0,"black"],[1,"white"]]},"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found null instead."},[0,0,0,1],[0,0,0,1],[0.5,0.5,0.5,1],[1,1,1,1],[1,1,1,1],{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."}]}},"legacy/exponential/color-default":{"expression":{"type":"exponential","property":"p","stops":[[0,"black"],[1,"white"]],"default":"red"},"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[1,0,0,1],[0,0,0,1],[0,0,0,1],[0.5,0.5,0.5,1],[1,1,1,1],[1,1,1,1],[1,0,0,1]]}},"legacy/exponential/color-hcl":{"expression":{"type":"exponential","property":"p","stops":[[0,"black"],[1,"white"]],"colorSpace":"hcl"},"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found null instead."},[0,0,0,1],[0,0,0,1],[0.466326,0.466326,0.466326,1],[1,1,1,1],[1,1,1,1],{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."}]}},"legacy/exponential/color-lab":{"expression":{"type":"exponential","property":"p","stops":[[0,"black"],[1,"white"]],"colorSpace":"lab"},"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found null instead."},[0,0,0,1],[0,0,0,1],[0.466326,0.466326,0.466326,1],[1,1,1,1],[1,1,1,1],{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."}]}},"legacy/exponential/composite":{"expression":{"type":"exponential","property":"p","stops":[[{"zoom":0,"value":0},0],[{"zoom":0,"value":1},1],[{"zoom":1,"value":0},2],[{"zoom":1,"value":1},3]]},"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{"zoom":0.5},{"properties":{}}],[{"zoom":0.5},{"properties":{"p":-1}}],[{"zoom":0.5},{"properties":{"p":0}}],[{"zoom":0.5},{"properties":{"p":0.5}}],[{"zoom":0.5},{"properties":{"p":1}}],[{"zoom":0.5},{"properties":{"p":2}}],[{"zoom":0.5},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":false,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property[4][2]: Expected value to be of type number, but found null instead."},1,1,1.5,2,2,{"error":"layers[0].paint.some-property[4][2]: Expected value to be of type number, but found string instead."}]}},"legacy/exponential/composite-default":{"expression":{"type":"exponential","property":"p","stops":[[{"zoom":0,"value":0},0],[{"zoom":0,"value":1},1],[{"zoom":1,"value":0},2],[{"zoom":1,"value":1},3]],"default":-1},"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{"zoom":0.5},{"properties":{}}],[{"zoom":0.5},{"properties":{"p":-1}}],[{"zoom":0.5},{"properties":{"p":0}}],[{"zoom":0.5},{"properties":{"p":0.5}}],[{"zoom":0.5},{"properties":{"p":1}}],[{"zoom":0.5},{"properties":{"p":2}}],[{"zoom":0.5},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":false,"type":"number"},"outputs":[-1,1,1,1.5,2,2,-1]}},"legacy/exponential/duplicate-stops":{"expression":{"type":"exponential","stops":[[0,10],[1,20],[1,25],[2,30]]},"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{"zoom":1},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"number"},"outputs":[20]}},"legacy/exponential/implicit":{"expression":{"property":"p","stops":[[0,0],[1,1]]},"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found null instead."},0,0,0.5,1,1,{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."}]}},"legacy/exponential/number":{"expression":{"type":"exponential","property":"p","stops":[[0,0],[1,1]]},"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found null instead."},0,0,0.5,1,1,{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."}]}},"legacy/exponential/number-default":{"expression":{"type":"exponential","property":"p","stops":[[0,0],[1,1]],"default":-1},"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-1,0,0,0.5,1,1,-1]}},"legacy/identity/array":{"expression":{"type":"identity","property":"p"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":[0]}}],[{},{"properties":{"p":[1]}}],[{},{"properties":{"p":"wrong type"}}]],"propertySpec":{"type":"array","value":"number","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[{"error":"layers[0].paint.some-property: Expected value to be of type array, but found null instead."},[0],[1],{"error":"layers[0].paint.some-property: Expected value to be of type array, but found string instead."}]}},"legacy/identity/array-default":{"expression":{"type":"identity","property":"p","default":[-1]},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":[0]}}],[{},{"properties":{"p":[1]}}],[{},{"properties":{"p":"wrong type"}}]],"propertySpec":{"type":"array","value":"number","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[[-1],[0],[1],[-1]]}},"legacy/identity/boolean":{"expression":{"type":"identity","property":"p"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":true}}],[{},{"properties":{"p":false}}],[{},{"properties":{"p":"wrong type"}}]],"propertySpec":{"type":"boolean","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[{"error":"layers[0].paint.some-property: Expected value to be of type boolean, but found null instead."},true,false,{"error":"layers[0].paint.some-property: Expected value to be of type boolean, but found string instead."}]}},"legacy/identity/boolean-default":{"expression":{"type":"identity","property":"p","default":true},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":true}}],[{},{"properties":{"p":false}}],[{},{"properties":{"p":"wrong type"}}]],"propertySpec":{"type":"boolean","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,false,true]}},"legacy/identity/color":{"expression":{"type":"identity","property":"p"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":"blue"}}],[{},{"properties":{"p":0}}]],"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[{"error":"layers[0].paint.some-property: Could not parse color from value 'null'"},[0,0,1,1],{"error":"layers[0].paint.some-property: Could not parse color from value '0'"}]}},"legacy/identity/color-default":{"expression":{"type":"identity","property":"p","default":"red"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":"blue"}}],[{},{"properties":{"p":0}}]],"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[1,0,0,1],[0,0,1,1],[1,0,0,1]]}},"legacy/identity/color_array":{"expression":{"type":"identity","property":"p"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":"blue"}}],[{},{"properties":{"p":["blue","red"]}}],[{},{"properties":{"p":[]}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":"not a color"}}]],"propertySpec":{"type":"colorArray","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"colorArray"},"outputs":[{"error":"layers[0].paint.some-property: Could not parse colorArray from value 'null'"},{"values":[{"r":0,"g":0,"b":1,"a":1}]},{"values":[{"r":0,"g":0,"b":1,"a":1},{"r":1,"g":0,"b":0,"a":1}]},{"values":[]},{"error":"layers[0].paint.some-property: Could not parse colorArray from value '0'"},{"error":"layers[0].paint.some-property: Could not parse colorArray from value 'not a color'"}]}},"legacy/identity/enum":{"expression":{"type":"identity","property":"p"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":"a"}}],[{},{"properties":{"p":"b"}}],[{},{"properties":{"p":"c"}}]],"propertySpec":{"type":"enum","values":{"a":{},"b":{}},"property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":[{"error":"layers[0].paint.some-property: Expected value to be of type string, but found null instead."},"a","b","c"]}},"legacy/identity/enum-default":{"expression":{"type":"identity","property":"p","default":"a"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":"a"}}],[{},{"properties":{"p":"b"}}],[{},{"properties":{"p":"c"}}]],"propertySpec":{"type":"enum","values":{"a":{},"b":{}},"property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["a","a","b","a"]}},"legacy/identity/number":{"expression":{"type":"identity","property":"p"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":"wrong type"}}]],"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property: Expected value to be of type number, but found null instead."},0,1,{"error":"layers[0].paint.some-property: Expected value to be of type number, but found string instead."}]}},"legacy/identity/number-default":{"expression":{"type":"identity","property":"p","default":-1},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":"wrong type"}}]],"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-1,0,1,-1]}},"legacy/identity/number_array":{"expression":{"type":"identity","property":"p"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":[2,3]}}],[{},{"properties":{"p":[]}}],[{},{"properties":{"p":"not a number"}}]],"propertySpec":{"type":"numberArray","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"numberArray"},"outputs":[{"error":"layers[0].paint.some-property: Could not parse numberArray from value 'null'"},{"values":[1]},{"values":[2,3]},{"values":[]},{"error":"layers[0].paint.some-property: Could not parse numberArray from value 'not a number'"}]}},"legacy/identity/padding":{"expression":{"type":"identity","property":"p"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":[1,2]}}],[{},{"properties":{"p":[1,2,3]}}],[{},{"properties":{"p":[1,2,3,4]}}],[{},{"properties":{"p":[]}}],[{},{"properties":{"p":"not a padding"}}]],"propertySpec":{"type":"padding","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"padding"},"outputs":[{"error":"layers[0].paint.some-property: Could not parse padding from value 'null'"},{"values":[1,1,1,1]},{"values":[1,2,1,2]},{"values":[1,2,3,2]},{"values":[1,2,3,4]},{"error":"layers[0].paint.some-property: Could not parse padding from value '[]'"},{"error":"layers[0].paint.some-property: Could not parse padding from value 'not a padding'"}]}},"legacy/identity/string":{"expression":{"type":"identity","property":"p"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":"0"}}],[{},{"properties":{"p":"1"}}],[{},{"properties":{"p":0}}]],"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":[{"error":"layers[0].paint.some-property: Expected value to be of type string, but found null instead."},"0","1",{"error":"layers[0].paint.some-property: Expected value to be of type string, but found number instead."}]}},"legacy/identity/string-default":{"expression":{"type":"identity","property":"p","default":"default"},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":"0"}}],[{},{"properties":{"p":"1"}}],[{},{"properties":{"p":0}}]],"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["default","0","1","default"]}},"legacy/interval/array":{"expression":{"type":"interval","property":"p","stops":[[0,[0]],[1,[1]]]},"propertySpec":{"type":"array","value":"number","length":1,"property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found null instead."},[0],[0],[0],[1],[1],{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found string instead."}]}},"legacy/interval/array-default":{"expression":{"type":"interval","property":"p","stops":[[0,[0]],[1,[1]]],"default":[-1]},"propertySpec":{"type":"array","value":"number","length":1,"property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[[-1],[0],[0],[0],[1],[1],[-1]]}},"legacy/interval/color":{"expression":{"type":"interval","property":"p","stops":[[0,"black"],[1,"white"]]},"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found null instead."},[0,0,0,1],[0,0,0,1],[0,0,0,1],[1,1,1,1],[1,1,1,1],{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found string instead."}]}},"legacy/interval/color-default":{"expression":{"type":"interval","property":"p","stops":[[0,"black"],[1,"white"]],"default":"red"},"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[1,0,0,1],[0,0,0,1],[0,0,0,1],[0,0,0,1],[1,1,1,1],[1,1,1,1],[1,0,0,1]]}},"legacy/interval/composite":{"expression":{"type":"interval","property":"p","stops":[[{"zoom":0,"value":0},0],[{"zoom":0,"value":1},1],[{"zoom":1,"value":0},2],[{"zoom":1,"value":1},3]]},"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"inputs":[[{"zoom":0.5},{"properties":{}}],[{"zoom":0.5},{"properties":{"p":-1}}],[{"zoom":0.5},{"properties":{"p":0}}],[{"zoom":0.5},{"properties":{"p":0.5}}],[{"zoom":0.5},{"properties":{"p":1}}],[{"zoom":0.5},{"properties":{"p":2}}],[{"zoom":0.5},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":false,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property[2][1]: Expected value to be of type number, but found null instead."},0,0,0,1,1,{"error":"layers[0].paint.some-property[2][1]: Expected value to be of type number, but found string instead."}]}},"legacy/interval/composite-default":{"expression":{"type":"interval","property":"p","stops":[[{"zoom":0,"value":0},0],[{"zoom":0,"value":1},1],[{"zoom":1,"value":0},2],[{"zoom":1,"value":1},3]],"default":-1},"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"inputs":[[{"zoom":0.5},{"properties":{}}],[{"zoom":0.5},{"properties":{"p":-1}}],[{"zoom":0.5},{"properties":{"p":0}}],[{"zoom":0.5},{"properties":{"p":0.5}}],[{"zoom":0.5},{"properties":{"p":1}}],[{"zoom":0.5},{"properties":{"p":2}}],[{"zoom":0.5},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":false,"type":"number"},"outputs":[-1,0,0,0,1,1,-1]}},"legacy/interval/duplicate-stops":{"expression":{"type":"interval","stops":[[0,"a"],[1,"b"],[1,"c"],[2,"d"]]},"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{"zoom":1},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"string"},"outputs":["b"]}},"legacy/interval/implicit":{"expression":{"property":"p","stops":[[0,"0"],[1,"1"]]},"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":false,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":[{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found null instead."},"0","0","0","1","1",{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found string instead."}]}},"legacy/interval/number":{"expression":{"type":"interval","property":"p","stops":[[0,0],[1,1]]},"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found null instead."},0,0,0,1,1,{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found string instead."}]}},"legacy/interval/number-default":{"expression":{"type":"interval","property":"p","stops":[[0,0],[1,1]],"default":-1},"propertySpec":{"type":"number","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-1,0,0,0,1,1,-1]}},"legacy/interval/string":{"expression":{"type":"interval","property":"p","stops":[[0,"0"],[1,"1"]]},"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":[{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found null instead."},"0","0","0","1","1",{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found string instead."}]}},"legacy/interval/string-default":{"expression":{"type":"interval","property":"p","stops":[[0,"0"],[1,"1"]],"default":"default"},"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":-1}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":0.5}}],[{},{"properties":{"p":1}}],[{},{"properties":{"p":2}}],[{},{"properties":{"p":"0"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["default","0","0","0","1","1","default"]}},"legacy/interval/tokens-property":{"expression":{"type":"interval","property":"p","stops":[[0,"0 {a}"],[1,"1 {b}"]]},"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]},"tokens":true},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":0,"a":"a"}}],[{},{"properties":{"p":1,"b":"b"}}],[{},{"properties":{"p":0}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":[{"error":"layers[0].paint.some-property[1]: Expected value to be of type number, but found null instead."},"0 {a}","1 {b}","0 {a}"]}},"legacy/interval/tokens-zoom":{"expression":{"type":"interval","stops":[[0,"0 {a}"],[1,"{b}"]]},"inputs":[[{"zoom":0},{"properties":{"a":"a","b":2}}],[{"zoom":1},{"properties":{"a":"a","b":2}}],[{"zoom":0},{"properties":{}}]],"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]},"tokens":true},"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":false,"type":"string"},"outputs":["0 a","2","0 "]}},"length/array":{"expression":["length",["array",["get","x"]]],"inputs":[[{},{"properties":{"x":[1,2,3,4,5]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[5]}},"length/empty-array":{"expression":["length",["literal",[]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"length/empty-string":{"expression":["length",""],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"length/implicit":{"expression":["length",["get","x"]],"inputs":[[{},{"properties":{"x":"a string"}}],[{},{"properties":{"x":[]}}],[{},{"properties":{"x":0}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[8,0,{"error":"layers[0].paint.some-property: Expected value to be of type string or array, but found number instead."}]}},"length/invalid-arg":{"expression":["length",0],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected argument of type string or array, but found number instead."}]}}},"length/missing-arg":{"expression":["length"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected 1 argument, but found 0 instead."}]}}},"length/string":{"expression":["length",["string",["get","x"]]],"inputs":[[{},{"properties":{"x":"a string"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[8]}},"length/surrogate-pair":{"expression":["length",["get","str"]],"inputs":[[{},{"properties":{"str":"\u5e02\u9547"}}],[{},{"properties":{"str":"\u4e10\ud85a\ude2d\u5e02\u9547"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[2,4]}},"length/unexpected-second-arg":{"expression":["length","abc","def"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected 1 argument, but found 2 instead."}]}}},"less/boolean":{"expression":["<",["boolean",["get","x"]],["boolean",["get","y"]]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"\"<\" comparisons are not supported for type 'boolean'."}]}}},"less/mismatch":{"expression":["<",["string",["get","x"]],["number",["get","y"]]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Cannot compare types 'string' and 'number'."}]}}},"less/null":{"expression":["<",null,null],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"\"<\" comparisons are not supported for type 'null'."}]}}},"less/number":{"expression":["<",["number",["get","x"]],["number",["get","y"]]],"inputs":[[{},{"properties":{"x":1,"y":1}}],[{},{"properties":{"x":1,"y":2}}],[{},{"properties":{"x":2,"y":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,false]}},"less/string":{"expression":["<",["string",["get","x"]],["string",["get","y"]]],"inputs":[[{},{"properties":{"x":"1","y":"1"}}],[{},{"properties":{"x":"1","y":"2"}}],[{},{"properties":{"x":"2","y":"1"}}],[{},{"properties":{"x":"abc","y":"azz"}}],[{},{"properties":{"x":"abc","y":"aaa"}}],[{},{"properties":{"x":"abc","y":"abc"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,false,true,false,false]}},"less/string-and-value":{"expression":["<",["string",["get","x"]],["get","y"]],"inputs":[[{},{"properties":{"x":"1","y":"2"}}],[{},{"properties":{"x":"1","y":2}}],[{},{"properties":{"x":1,"y":"2"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,{"error":"layers[0].paint.some-property: Expected value to be of type string, but found number instead."},{"error":"layers[0].paint.some-property[1]: Expected value to be of type string, but found number instead."}]}},"less/value":{"expression":["<",["get","x"],["get","y"]],"inputs":[[{},{"properties":{"x":"10","y":"2"}}],[{},{"properties":{"x":1,"y":10}}],[{},{"properties":{"x":"1","y":1}}],[{},{"properties":{"x":1,"y":"1"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,{"error":"layers[0].paint.some-property: Expected arguments for \"<\" to be (string, string) or (number, number), but found (string, number) instead."},{"error":"layers[0].paint.some-property: Expected arguments for \"<\" to be (string, string) or (number, number), but found (number, string) instead."}]}},"less_or_equal/boolean":{"expression":["<=",["boolean",["get","x"]],["boolean",["get","y"]]],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"\"<=\" comparisons are not supported for type 'boolean'."}]}}},"less_or_equal/mismatch":{"expression":["<=",["string",["get","x"]],["number",["get","y"]]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Cannot compare types 'string' and 'number'."}]}}},"less_or_equal/null":{"expression":["<=",null,null],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"\"<=\" comparisons are not supported for type 'null'."}]}}},"less_or_equal/number":{"expression":["<=",["number",["get","x"]],["number",["get","y"]]],"inputs":[[{},{"properties":{"x":1,"y":1}}],[{},{"properties":{"x":1,"y":2}}],[{},{"properties":{"x":2,"y":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,false]}},"less_or_equal/string":{"expression":["<=",["string",["get","x"]],["string",["get","y"]]],"inputs":[[{},{"properties":{"x":"1","y":"1"}}],[{},{"properties":{"x":"1","y":"2"}}],[{},{"properties":{"x":"2","y":"1"}}],[{},{"properties":{"x":"abc","y":"azz"}}],[{},{"properties":{"x":"abc","y":"aaa"}}],[{},{"properties":{"x":"abc","y":"abc"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,false,true,false,true]}},"less_or_equal/string-and-value":{"expression":["<",["string",["get","x"]],["get","y"]],"inputs":[[{},{"properties":{"x":"1","y":"1"}}],[{},{"properties":{"x":"1","y":1}}],[{},{"properties":{"x":1,"y":"1"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,{"error":"layers[0].paint.some-property: Expected value to be of type string, but found number instead."},{"error":"layers[0].paint.some-property[1]: Expected value to be of type string, but found number instead."}]}},"less_or_equal/value":{"expression":["<=",["get","x"],["get","y"]],"inputs":[[{},{"properties":{"x":"10","y":"2"}}],[{},{"properties":{"x":1,"y":10}}],[{},{"properties":{"x":"1","y":1}}],[{},{"properties":{"x":1,"y":"1"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,{"error":"layers[0].paint.some-property: Expected arguments for \"<=\" to be (string, string) or (number, number), but found (string, number) instead."},{"error":"layers[0].paint.some-property: Expected arguments for \"<=\" to be (string, string) or (number, number), but found (number, string) instead."}]}},"let/basic":{"expression":["let","a",["number",["get","a"]],"b",["number",["get","b"]],["+",["+",["var","a"],["var","b"]],["var","a"]]],"inputs":[[{},{"properties":{"a":1,"b":2}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[4]}},"let/expected-value":{"expression":["let","density",["/",["get","population"],["get","sq-km"]],["interpolate",["linear"],["var","density"],1,"#000000",100,"#ffffff"]],"propertySpec":{"type":"color","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"description":"let should preserve expected type for child expressions. If the expected 'color' type in this test were not preserved, it would not get passed into the interpolation and the literal strings wouldn't automatically coerce to the 'color' type","inputs":[[{},{"properties":{"population":100,"sq-km":10}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[0.090909,0.090909,0.090909,1]]}},"let/invalid-name":{"expression":["let","$a",1,["var","$a"]],"inputs":[],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"Variable names must contain only alphanumeric characters or '_'."}]}}},"let/nested":{"expression":["let","a",["number",["get","a"]],["let","b",["+",1,["var","a"]],["+",["var","a"],["var","b"]]]],"inputs":[[{},{"properties":{"a":1}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[3]}},"let/property-function":{"expression":["let","a",["get","x"],["+",1,["number",["var","a"]]]],"inputs":[[{},{"properties":{"x":5}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[6]}},"let/shadow":{"expression":["let","a",["get","one"],["let","a",["get","two"],["var","a"]]],"inputs":[[{},{"properties":{"one":1,"two":2}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":[2]}},"let/unbound":{"expression":["let","a",1,["+",["+",["var","a"],["var","b"]],["var","a"]]],"expected":{"compiled":{"result":"error","errors":[{"key":"[3][1][2][1]","error":"Unknown variable \"b\". Make sure \"b\" has been bound in an enclosing \"let\" expression before using it."}]}}},"let/zoom":{"expression":["let","z0_value",["number",["get","a"]],"z20_value",["number",["get","b"]],["interpolate",["linear"],["zoom"],0,["var","z0_value"],20,["var","z20_value"]]],"inputs":[[{"zoom":10},{"properties":{"a":10,"b":30}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":false,"type":"number"},"outputs":[20]}},"literal/boolean-array":{"expression":["literal",[true,false]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[[true,false]]}},"literal/empty":{"expression":["literal"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"'literal' expression requires exactly one argument, but found 0 instead."}]}}},"literal/infer-empty-array-type":{"propertySpec":{"type":"array","value":"number","property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["literal",[]],"inputs":[],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[]}},"literal/literal-false":{"expression":["literal",false],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"literal/literal-null":{"expression":["literal",null],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"null"},"outputs":[null]}},"literal/literal-number":{"expression":["literal",7.5],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[7.5]}},"literal/literal-string":{"expression":["literal","hello"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["hello"]}},"literal/literal-true":{"expression":["literal",true],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[true]}},"literal/mixed-primitive-array":{"expression":["literal",[1,"2"]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[[1,"2"]]}},"literal/multiple-args":{"expression":["literal",{},[]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"'literal' expression requires exactly one argument, but found 2 instead."}]}}},"literal/nested-array":{"expression":["literal",[1,[3,4]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[[1,[3,4]]]}},"literal/number-array":{"expression":["literal",[1,2]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[[1,2]]}},"literal/object":{"expression":["literal",{"x":1}],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"object"},"outputs":[{"x":1}]}},"literal/string":{"expression":"ahoy!","inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["ahoy!"]}},"literal/string-array":{"expression":["literal",["1","2"]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[["1","2"]]}},"literal/u2028u2029":{"expression":"ah\u2028\u2029oy!","inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["ah\u2028\u2029oy!"]}},"ln/basic":{"expression":["ln",["e"]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[1]}},"ln2/basic":{"expression":["ln2"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[0.693147]}},"log10/basic":{"expression":["log10",100],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[2]}},"log2/basic":{"expression":["log2",1024],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[10]}},"match/arity-0":{"expression":["match"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected at least 4 arguments, but found only 0."}]}}},"match/arity-1":{"expression":["match","x"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected at least 4 arguments, but found only 1."}]}}},"match/arity-2":{"expression":["match","x","y"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected at least 4 arguments, but found only 2."}]}}},"match/arity-3":{"expression":["match","x","y","z"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected at least 4 arguments, but found only 3."}]}}},"match/basic":{"expression":["match",["get","x"],"a","Apple","b","Banana","Kumquat"],"inputs":[[{},{"properties":{"x":"a"}}],[{},{"properties":{"x":"b"}}],[{},{"properties":{"x":"c"}}],[{},{"properties":{"x":0}}],[{},{"properties":{}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["Apple","Banana","Kumquat","Kumquat","Kumquat"]}},"match/empty-case":{"expression":["match",["get","x"],[],"thing one","thing two"],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"Expected at least one branch label."}]}}},"match/infer-array-type":{"propertySpec":{"type":"array","value":"string","property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["match",["number",["get","x"]],0,["literal",["one"]],10,["literal",["one","two"]],["literal",["one","two","three"]]],"inputs":[],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[]}},"match/label-array":{"expression":["match","x",["string",["get","y"]],"thing one","thing two"],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"Branch labels must be numbers or strings."}]}}},"match/label-boolean":{"expression":["match",["boolean",["get","x"]],true,"match","otherwise"],"inputs":[[{},{"properties":{"x":true}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":"true"}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":null}}],[{},{"properties":{}}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"Branch labels must be numbers or strings."}]}}},"match/label-booleans":{"expression":["match",["boolean",["get","x"]],[true],"match","otherwise"],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"Branch labels must be numbers or strings."}]}}},"match/label-mixed":{"expression":["match",["get","x"],["0",0],"zero","otherwise"],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"Expected string but found number instead."}]}}},"match/label-non-integer":{"expression":["match",1,1.5,"thing one","thing two"],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"Numeric branch labels must be integer values."}]}}},"match/label-null":{"expression":["match",null,null,"match","otherwise"],"inputs":[[{},{"properties":{}}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"Branch labels must be numbers or strings."}]}}},"match/label-number":{"expression":["match",["get","x"],0,"match","otherwise"],"inputs":[[{},{"properties":{"x":0}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":0.5}}],[{},{"properties":{"x":"0"}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":null}}],[{},{"properties":{}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["match","otherwise","otherwise","otherwise","otherwise","otherwise","otherwise"]}},"match/label-numbers":{"expression":["match",["get","x"],[-2,-1],"negative",0,"zero",[1,2],"positive","otherwise"],"inputs":[[{},{"properties":{"x":-2}}],[{},{"properties":{"x":-1}}],[{},{"properties":{"x":0}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":2}}],[{},{"properties":{"x":99}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["negative","negative","zero","positive","positive","otherwise"]}},"match/label-object":{"expression":["match","x",{},"thing one","thing two"],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"Branch labels must be numbers or strings."}]}}},"match/label-overflow":{"expression":["match",0,10000000000000000,"thing one","thing two"],"expected":{"compiled":{"result":"error","errors":[{"key":"[2]","error":"Branch labels must be integers no larger than 9007199254740991."}]}}},"match/label-string":{"expression":["match",["string",["get","x"]],"0","match","otherwise"],"inputs":[[{},{"properties":{"x":"0"}}],[{},{"properties":{"x":"1"}}],[{},{"properties":{"x":0}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":null}}],[{},{"properties":{}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["match","otherwise",{"error":"layers[0].paint.some-property[1]: Expected value to be of type string, but found number instead."},{"error":"layers[0].paint.some-property[1]: Expected value to be of type string, but found boolean instead."},{"error":"layers[0].paint.some-property[1]: Expected value to be of type string, but found null instead."},{"error":"layers[0].paint.some-property[1]: Expected value to be of type string, but found null instead."}]}},"match/label-string-invalid-identifier":{"expression":["match",["string",["get","x"]],["0-1","a.b","{}","weird\u2028json\u2029issue"],"match","otherwise"],"inputs":[[{},{"properties":{"x":"0-1"}}],[{},{"properties":{"x":"a.b"}}],[{},{"properties":{"x":"{}"}}],[{},{"properties":{"x":"weird\u2028json\u2029issue"}}],[{},{"properties":{"x":"1"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["match","match","match","match","otherwise"]}},"match/label-strings":{"expression":["match",["get","x"],["-2","-1"],"negative","0","zero",["1","2"],"positive","otherwise"],"inputs":[[{},{"properties":{"x":"-2"}}],[{},{"properties":{"x":"-1"}}],[{},{"properties":{"x":"0"}}],[{},{"properties":{"x":"1"}}],[{},{"properties":{"x":"2"}}],[{},{"properties":{"x":"two"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["negative","negative","zero","positive","positive","otherwise"]}},"match/labels-mixed-1":{"expression":["match",["get","x"],"a","the letter a",0,"the number 0","otherwise"],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Expected string but found number instead."}]}}},"match/labels-mixed-2":{"expression":["match",["get","x"],"a","the letter a",[0],"the number 0 in an array","otherwise"],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Expected string but found number instead."}]}}},"match/labels-mixed-3":{"expression":["match",["get","x"],["a"],"the letter a in an array",0,"the number 0","otherwise"],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Expected string but found number instead."}]}}},"match/labels-mixed-4":{"expression":["match",["get","x"],["a"],"the letter a in an array",[0],"the number 0 in an array","otherwise"],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Expected string but found number instead."}]}}},"match/mismatch-input":{"expression":["match",["string",["get","x"]],0,"match","otherwise"],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"Expected number but found string instead."}]}}},"match/mismatch-input-literal-number":{"expression":["match",0,"0","match 1",["10"],"match 2","otherwise"],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"Expected string but found number instead."}]}}},"match/mismatch-input-literal-string":{"expression":["match","0",0,"match 1",[10],"match 2","otherwise"],"expected":{"compiled":{"result":"error","errors":[{"key":"[1]","error":"Expected number but found string instead."}]}}},"match/mismatch-output":{"expression":["match",["string",["get","x"]],"0","match",false],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Expected string but found boolean instead."}]}}},"match/output-expression":{"expression":["match",["get","id"],"a",["get","a"],"b",["get","b"],["get","c"]],"inputs":[[{},{"properties":{"id":"a","a":"A","b":"B"}}],[{},{"properties":{"id":"b","a":"A","b":"B"}}],[{},{"properties":{"id":"z","a":"A","b":"B"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":["A","B",null]}},"match/output-null":{"expression":["match",1,0,["get","a"],1,null,"otherwise"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":[null]}},"match/unreachable-branch-1":{"expression":["match",["string",["get","x"]],"0","match","0","match","otherwise"],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Branch labels must be unique."}]}}},"match/unreachable-branch-2":{"expression":["match",["string",["get","x"]],["0","1"],"match",["0","2"],"match","otherwise"],"expected":{"compiled":{"result":"error","errors":[{"key":"[4]","error":"Branch labels must be unique."}]}}},"max/arity-0":{"expression":["all",["<",["max"],0],["==",0,["/",1,["max"]]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[true]}},"max/arity-1":{"expression":["max",1],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[1]}},"max/basic":{"expression":["max",0,-1,100],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[100]}},"min/arity-0":{"expression":["all",[">",["min"],0],["==",0,["/",1,["min"]]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[true]}},"min/arity-1":{"expression":["min",1],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[1]}},"min/basic":{"expression":["min",0,-1,10],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[-1]}},"minus/arity-0":{"expression":["-"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected arguments of type (number, number) | (number), but found () instead."}]}}},"minus/arity-1":{"expression":["-",5],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[-5]}},"minus/basic":{"expression":["-",5,7],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[-2]}},"minus/inference-arity-2":{"expression":["-",["get","x"],7],"inputs":[[{},{"properties":{"x":0}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-7]}},"mod/basic":{"expression":["%",18,12],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[6]}},"not/basic":{"expression":["!",["boolean",["get","x"]]],"inputs":[[{},{"properties":{"x":true}}],[{},{"properties":{"x":false}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true]}},"not_equal/mismatch":{"expression":["!=",["string",["get","x"]],["number",["get","y"]]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Cannot compare types 'string' and 'number'."}]}}},"not_equal/null":{"expression":["!=",null,["get","x"]],"inputs":[[{},{"properties":{}}],[{},{"properties":{"x":null}}],[{},{"properties":{"x":false}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,true]}},"not_equal/number":{"expression":["!=",["number",["get","x"]],["get","y"]],"inputs":[[{},{"properties":{"x":1,"y":1}}],[{},{"properties":{"x":1,"y":"1"}}],[{},{"properties":{"x":1,"y":2}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,true]}},"not_equal/string":{"expression":["!=",["string",["get","x"]],["get","y"]],"inputs":[[{},{"properties":{"x":"1","y":"1"}}],[{},{"properties":{"x":"1","y":1}}],[{},{"properties":{"x":"1","y":"2"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,true]}},"not_equal/value":{"expression":["!=",["get","x"],["get","y"]],"inputs":[[{},{"properties":{"x":0,"y":0}}],[{},{"properties":{"x":"0","y":"0"}}],[{},{"properties":{"x":0,"y":false}}],[{},{"properties":{"x":0,"y":"0"}}],[{},{"properties":{"x":0,"y":null}}],[{},{"properties":{"x":"0","y":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,true,true,true,true]}},"number-format/currency":{"expression":["number-format",123456.789,{"locale":["get","locale"],"currency":["get","currency"]}],"inputs":[[{},{"properties":{"locale":"en-US","currency":"JPY"}}],[{},{"properties":{"locale":"en-US","currency":"EUR"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["\u00a5123,457","\u20ac123,456.79"]}},"number-format/currency-and-unit":{"expression":["number-format",1234.567,{"locale":["get","locale"],"currency":["get","currency"],"unit":["get","unit"]}],"inputs":[[{},{"properties":{"locale":"en-US","currency":"EUR","unit":"meter"}}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"NumberFormat options `currency` and `unit` are mutually exclusive"}]}}},"number-format/default":{"expression":["number-format",123456.789,{}],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["123,456.789"]}},"number-format/precision":{"expression":["number-format",987654321.234567,{"locale":["get","locale"],"min-fraction-digits":["get","min"],"max-fraction-digits":["get","max"]}],"inputs":[[{},{"properties":{"locale":"en-US","min":15,"max":20}}],[{},{"properties":{"locale":"en-US","min":2,"max":4}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["987,654,321.234567000000000","987,654,321.2346"]}},"number-format/unit":{"expression":["number-format",1234.567,{"locale":["get","locale"],"unit":["get","unit"]}],"inputs":[[{},{"properties":{"locale":"en-US","unit":"meter"}}],[{},{"properties":{"locale":"en-US","unit":"celsius"}}],[{},{"properties":{"locale":"en-US","unit":"kilobyte"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["1,234.567 m","1,234.567\u00b0C","1,234.567 kB"]}},"number/basic":{"expression":["number",["get","x"]],"inputs":[[{},{"properties":{"x":1}}],[{},{"properties":{"x":"1"}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[1,{"error":"layers[0].paint.some-property: Expected value to be of type number, but found string instead."},{"error":"layers[0].paint.some-property: Expected value to be of type number, but found boolean instead."},{"error":"layers[0].paint.some-property: Expected value to be of type number, but found null instead."}]}},"number/default-value":{"expression":["number",["get","x"],-1],"inputs":[[{},{"properties":{"x":1}}],[{},{"properties":{"x":"1"}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[1,-1,-1,-1]}},"object/basic":{"expression":["object",["get","x"]],"inputs":[[{},{"properties":{"x":{"inner":"object"}}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":"1"}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"object"},"outputs":[{"inner":"object"},{"error":"layers[0].paint.some-property: Expected value to be of type object, but found number instead."},{"error":"layers[0].paint.some-property: Expected value to be of type object, but found string instead."},{"error":"layers[0].paint.some-property: Expected value to be of type object, but found boolean instead."},{"error":"layers[0].paint.some-property: Expected value to be of type object, but found null instead."}]}},"object/default-value":{"expression":["object",["get","x"],["literal",{"default":"value"}]],"inputs":[[{},{"properties":{"x":{"inner":"object"}}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":"1"}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"object"},"outputs":[{"inner":"object"},{"default":"value"},{"default":"value"},{"default":"value"},{"default":"value"}]}},"object/implicit":{"expression":["get","b",["get","a"]],"inputs":[[{},{"properties":{"a":{"b":0}}}],[{},{"properties":{"a":"not"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":[0,{"error":"layers[0].paint.some-property[2]: Expected value to be of type object, but found string instead."}]}},"parse/empty":{"expression":[],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected an array with at least one element. If you wanted a literal array, use [\"literal\", []]."}]}}},"parse/non-array":{"expression":["+",["-",0,{}],10],"expected":{"compiled":{"result":"error","errors":[{"key":"[1][2]","error":"Bare objects invalid. Use [\"literal\", {...}] instead."}]}}},"parse/non-string":{"expression":[1,2],"expected":{"compiled":{"result":"error","errors":[{"key":"[0]","error":"Expression name must be a string, but found number instead. If you wanted a literal array, use [\"literal\", [...]]."}]}}},"parse/unknown-expression":{"expression":["+",["*",1,2,3,["FAKE-EXPRESSION",1]],10],"expected":{"compiled":{"result":"error","errors":[{"key":"[1][4][0]","error":"Unknown expression \"FAKE-EXPRESSION\". If you wanted a literal array, use [\"literal\", [...]]."}]}}},"pi/basic":{"expression":["pi"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[3.14159]}},"plus/arity-0":{"expression":["+"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"plus/arity-1":{"expression":["+",1],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[1]}},"plus/basic":{"expression":["+",1,2,3,4],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[10]}},"pow/basic":{"expression":["^",4,["number",["get","x"]]],"inputs":[[{},{"properties":{"x":2}}],[{},{"properties":{"x":0.5}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[16,2]}},"properties/basic":{"expression":["properties"],"inputs":[[{},{"properties":{"x":5}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"object"},"outputs":[{"x":5}]}},"resolved-locale/basic":{"expression":["==",["resolved-locale",["collator",{"case-sensitive":true,"diacritic-sensitive":true,"locale":"en"}]],"en"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"boolean"},"outputs":[true]}},"rgb/basic":{"expression":["rgb",0,0,255],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"color"},"outputs":[[0,0,1,1]]}},"rgba/basic":{"expression":["rgba",0,0,255,1],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"color"},"outputs":[[0,0,1,1]]}},"rgba/out-of-bounds":{"expression":["rgba",0,0,["number",["get","b"]],["number",["get","a"]]],"inputs":[[{},{"properties":{"b":-1,"a":1}}],[{},{"properties":{"b":256,"a":1}}],[{},{"properties":{"b":255,"a":-0.5}}],[{},{"properties":{"b":256,"a":1.5}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[{"error":"layers[0].paint.some-property: Invalid rgba value [0, 0, -1, 1]: 'r', 'g', and 'b' must be between 0 and 255."},{"error":"layers[0].paint.some-property: Invalid rgba value [0, 0, 256, 1]: 'r', 'g', and 'b' must be between 0 and 255."},{"error":"layers[0].paint.some-property: Invalid rgba value [0, 0, 255, -0.5]: 'a' must be between 0 and 1."},{"error":"layers[0].paint.some-property: Invalid rgba value [0, 0, 256, 1.5]: 'r', 'g', and 'b' must be between 0 and 255."}]}},"round/basic":{"expression":["round",["get","x"]],"inputs":[[{},{"properties":{"x":-2.6}}],[{},{"properties":{"x":-2.5}}],[{},{"properties":{"x":-2.4}}],[{},{"properties":{"x":-2}}],[{},{"properties":{"x":2.6}}],[{},{"properties":{"x":2.5}}],[{},{"properties":{"x":2.4}}],[{},{"properties":{"x":2}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[-3,-3,-2,-2,3,3,2,2]}},"semiliteral/directional-offset":{"expression":["semiliteral",[["round",["*",["cos",["get","direction"]],["get","magnitude"]]],["round",["*",["sin",["get","direction"]],["get","magnitude"]]]]],"inputs":[[{},{"properties":{"direction":0,"magnitude":10}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[[10,0]]}},"semiliteral/empty":{"expression":["semiliteral"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"'semiliteral' expression requires exactly one argument, but found 0 instead."}]}}},"semiliteral/empty-array":{"expression":["semiliteral",[]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[[]]}},"semiliteral/extra-arg":{"expression":["semiliteral",[],[]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"'semiliteral' expression requires exactly one argument, but found 2 instead."}]}}},"semiliteral/get-offset-components":{"expression":["semiliteral",[["number",["get","x"]],["number",["get","y"]]]],"inputs":[[{},{"properties":{"x":1,"y":2}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[[1,2]]}},"semiliteral/mixed-expressions":{"expression":["semiliteral",[["*",2,3],["concat","x","y"]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[[6,"xy"]]}},"semiliteral/mixed-primitives":{"expression":["semiliteral",["x",1]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[["x",1]]}},"semiliteral/number-array":{"expression":["semiliteral",[1,2]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[[1,2]]}},"semiliteral/scale-offset":{"expression":["let","dp_per_em",12,["semiliteral",[["/",6,["var","dp_per_em"]],["/",18,["var","dp_per_em"]]]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[[0.5,1.5]]}},"semiliteral/string":{"expression":["semiliteral","example"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["example"]}},"semiliteral/string-array":{"expression":["semiliteral",["x","y"]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[["x","y"]]}},"sin/basic":{"expression":["sin",0],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"slice/array-one-index":{"expression":["slice",["get","val"],["get","index"]],"inputs":[[{},{"properties":{"val":[],"index":0}}],[{},{"properties":{"val":[0,1,2,3,4,5],"index":-1}}],[{},{"properties":{"val":[0,1,2,3,4,5],"index":0}}],[{},{"properties":{"val":[0,1,2,3,4,5],"index":2}}],[{},{"properties":{"val":[0,1,2,3,4,5],"index":5}}],[{},{"properties":{"val":[0,1,2,3,4,5],"index":6}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":[[],[5],[0,1,2,3,4,5],[2,3,4,5],[5],[]]}},"slice/array-two-indexes":{"expression":["slice",["get","val"],["get","i1"],["get","i2"]],"inputs":[[{},{"properties":{"val":[0,1,2,3,4,5],"i1":1,"i2":1}}],[{},{"properties":{"val":[0,1,2,3,4,5],"i1":1,"i2":4}}],[{},{"properties":{"val":[0,1,2,3,4,5],"i1":3,"i2":1}}],[{},{"properties":{"val":[0,1,2,3,4,5],"i1":3,"i2":-1}}],[{},{"properties":{"val":[0,1,2,3,4,5],"i1":-3,"i2":-1}}],[{},{"properties":{"val":[0,1,2,3,4,5],"i1":-3,"i2":5}}],[{},{"properties":{"val":[0,1,2,3,4,5],"i1":0,"i2":99}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":[[],[1,2,3],[],[3,4],[3,4],[3,4],[0,1,2,3,4,5]]}},"slice/invalid-input-arg":{"expression":["slice",["get","input"],1],"inputs":[[{},{"properties":{"input":false}}],[{},{"properties":{"input":null}}],[{},{"properties":{"input":12}}],[{},{"properties":{"input":{}}}],[{},{"properties":{}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":[{"error":"layers[0].paint.some-property: Expected first argument to be of type array or string, but found boolean instead."},{"error":"layers[0].paint.some-property: Expected first argument to be of type array or string, but found null instead."},{"error":"layers[0].paint.some-property: Expected first argument to be of type array or string, but found number instead."},{"error":"layers[0].paint.some-property: Expected first argument to be of type array or string, but found object instead."},{"error":"layers[0].paint.some-property: Expected first argument to be of type array or string, but found null instead."}]}},"slice/invalid-input-arg-literal":{"expression":["slice",true,0],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected first argument to be of type array or string, but found boolean instead"}]}}},"slice/invalid-start-index":{"expression":["slice","abc",["get","i1"]],"inputs":[[{},{"properties":{"i1":false}}],[{},{"properties":{"i1":null}}],[{},{"properties":{"i1":"one"}}],[{},{"properties":{"i1":{}}}],[{},{"properties":{"i1":[1]}}],[{},{"properties":{}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":[{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found boolean instead."},{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found null instead."},{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found string instead."},{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found object instead."},{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found array instead."},{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found null instead."}]}},"slice/missing-input-arg":{"expression":["slice"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected 2 or 3 arguments, but found 0 instead."}]}}},"slice/missing-start-index-arg":{"expression":["slice","abc"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected 2 or 3 arguments, but found 1 instead."}]}}},"slice/string-one-index":{"expression":["slice",["get","val"],["get","index"]],"inputs":[[{},{"properties":{"val":"","index":0}}],[{},{"properties":{"val":"012345","index":-1}}],[{},{"properties":{"val":"012345","index":0}}],[{},{"properties":{"val":"012345","index":2}}],[{},{"properties":{"val":"012345","index":5}}],[{},{"properties":{"val":"012345","index":6}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":["","5","012345","2345","5",""]}},"slice/string-two-indexes":{"expression":["slice",["get","val"],["get","i1"],["get","i2"]],"inputs":[[{},{"properties":{"val":"012345","i1":1,"i2":1}}],[{},{"properties":{"val":"012345","i1":1,"i2":4}}],[{},{"properties":{"val":"012345","i1":3,"i2":1}}],[{},{"properties":{"val":"012345","i1":3,"i2":-1}}],[{},{"properties":{"val":"012345","i1":-3,"i2":-1}}],[{},{"properties":{"val":"012345","i1":-3,"i2":5}}],[{},{"properties":{"val":"012345","i1":0,"i2":99}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":["","123","","34","34","34","012345"]}},"slice/surrogate-pair":{"expression":["slice",["get","str"],["get","i1"]],"inputs":[[{},{"properties":{"str":"\u5e02\u9547","i1":1}}],[{},{"properties":{"str":"\u4e10\ud85a\ude2d\u5e02\u9547","i1":2}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"value"},"outputs":["\u9547","\u5e02\u9547"]}},"slice/unexpected-fourth-arg":{"expression":["slice","abc",0,1,42],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected 2 or 3 arguments, but found 4 instead."}]}}},"split/empty-separator":{"expression":["split","string",""],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[["s","t","r","i","n","g"]]}},"split/number-like":{"expression":["split","1+2+3+4","+"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[["1","2","3","4"]]}},"split/separator-multiple":{"expression":["split","needle","e"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[["n","","dl",""]]}},"split/separator-not-found":{"expression":["split","needle","haystack"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[["needle"]]}},"sqrt/basic":{"expression":["sqrt",["get","x"]],"inputs":[[{},{"properties":{"x":4}}],[{},{"properties":{"x":0.25}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[2,0.5]}},"step/basic":{"expression":["number",["step",["number",["get","x"]],11,0,111,1,1111]],"inputs":[[{},{"properties":{"x":-1.5}}],[{},{"properties":{"x":-0.5}}],[{},{"properties":{"x":0}}],[{},{"properties":{"x":0.5}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":1.5}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[11,11,111,111,1111,1111]}},"step/color":{"propertySpec":{"type":"color","property-type":"data-driven","expression":{"parameters":["feature"]}},"expression":["step",["get","x"],"#ddd",50,"#eee",100,["get","color"]],"inputs":[[{},{"properties":{"x":49,"color":"white"}}],[{},{"properties":{"x":50,"color":"white"}}],[{},{"properties":{"x":51,"color":"white"}}],[{},{"properties":{"x":99,"color":"white"}}],[{},{"properties":{"x":100,"color":"white"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[0.866666,0.866666,0.866666,1],[0.933333,0.933333,0.933333,1],[0.933333,0.933333,0.933333,1],[0.933333,0.933333,0.933333,1],[1,1,1,1]]}},"step/duplicate_stops":{"expression":["step",0,"a",0,"b",0,"c"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"[5]","error":"Input/output pairs for \"step\" expressions must be arranged with input values in strictly ascending order."}]}}},"step/projection/step-array":{"propertySpec":{"type":"projectionDefinition","property-type":"data-constant","expression":{"interpolated":true,"parameters":["zoom"]}},"expression":["step",["zoom"],["literal",["vertical-perspective","mercator",0.9]],10,"mercator"],"inputs":[[{"zoom":5},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"projectionDefinition"},"outputs":[["vertical-perspective","mercator",0.9]]}},"step/projection/step-primitive":{"propertySpec":{"type":"projectionDefinition","property-type":"data-constant","expression":{"interpolated":true,"parameters":["zoom"]}},"expression":["step",["zoom"],"vertical-perspective",10,"mercator"],"inputs":[[{"zoom":5},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"projectionDefinition"},"outputs":["vertical-perspective"]}},"string/basic":{"expression":["string",["get","x"]],"inputs":[[{},{"properties":{"x":"1"}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["1",{"error":"layers[0].paint.some-property: Expected value to be of type string, but found number instead."},{"error":"layers[0].paint.some-property: Expected value to be of type string, but found boolean instead."},{"error":"layers[0].paint.some-property: Expected value to be of type string, but found null instead."}]}},"string/default-value":{"expression":["string",["get","x"],"default"],"inputs":[[{},{"properties":{"x":"1"}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["1","default","default","default"]}},"tan/basic":{"expression":["tan",0.7853981633974483],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[0.999999]}},"times/arity-0":{"expression":["*"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[1]}},"times/arity-1":{"expression":["*",0],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[0]}},"times/basic":{"expression":["*",3,2,0.5,2],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"number"},"outputs":[6]}},"to-boolean/2-ary":{"expression":["to-boolean",["get","x"],["get","y"]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected one argument."}]}}},"to-boolean/basic":{"expression":["to-boolean",["get","x"]],"inputs":[[{},{}],[{},{"properties":{"x":true}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":""}}],[{},{"properties":{"x":"false"}}],[{},{"properties":{"x":0}}],[{},{"properties":{"x":1}}],[{},{"properties":{"x":null}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,false,false,true,false,true,false]}},"to-color/2-ary":{"expression":["to-color",["get","x"],["get","y"]],"inputs":[[{},{"properties":{"x":"red","y":"#000000"}}],[{},{"properties":{"x":"invalid","y":"#000000"}}],[{},{"properties":{"x":"rgba(0, 255, 0, 1)","y":"#000000"}}],[{},{"properties":{"x":[0,255,0,1],"y":"#000000"}}],[{},{"properties":{"x":[0,255,0],"y":"#000000"}}],[{},{"properties":{"x":[0,255],"y":"#000000"}}],[{},{"properties":{"x":[0,255],"y":"invalid"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[1,0,0,1],[0,0,0,1],[0,1,0,1],[0,1,0,1],[0,1,0,1],[0,0,0,1],{"error":"layers[0].paint.some-property: Could not parse color from value 'invalid'"}]}},"to-color/basic":{"expression":["to-color",["get","x"]],"inputs":[[{},{"properties":{"x":"red"}}],[{},{"properties":{"x":"invalid"}}],[{},{"properties":{"x":"__proto__"}}],[{},{"properties":{"x":"rgba(0, 255, 0, 1)"}}],[{},{"properties":{"x":[0,255,0,1]}}],[{},{"properties":{"x":[0,255,0]}}],[{},{"properties":{"x":[0,255]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"color"},"outputs":[[1,0,0,1],{"error":"layers[0].paint.some-property: Could not parse color from value 'invalid'"},{"error":"layers[0].paint.some-property: Could not parse color from value '__proto__'"},[0,1,0,1],[0,1,0,1],[0,1,0,1],{"error":"layers[0].paint.some-property: Invalid rgba value [0,255]: expected an array containing either three or four numeric values."}]}},"to-color/color":{"expression":["to-color",["rgba",0,0,0,1]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"color"},"outputs":[[0,0,0,1]]}},"to-number/2-ary":{"expression":["to-number",["get","x"],["get","y"]],"inputs":[[{},{"properties":{"x":1,"y":-1}}],[{},{"properties":{"x":"1","y":-1}}],[{},{"properties":{"x":"6.02e-23","y":-1}}],[{},{"properties":{"x":"Not a number","y":-1}}],[{},{"properties":{"x":null,"y":-1}}],[{},{"properties":{"x":[1,2],"y":-1}}],[{},{"properties":{"x":{"y":1},"y":-1}}],[{},{"properties":{"y":-1}}],[{},{"properties":{"y":"bad fallback"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[1,1,6.02e-23,-1,0,-1,-1,0,0]}},"to-number/basic":{"expression":["to-number",["get","x"]],"inputs":[[{},{"properties":{"x":1}}],[{},{"properties":{"x":"1"}}],[{},{"properties":{"x":"6.02e-23"}}],[{},{"properties":{"x":"Not a number"}}],[{},{"properties":{"x":null}}],[{},{"properties":{"x":[1,2]}}],[{},{"properties":{"x":{"y":1}}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"number"},"outputs":[1,1,6.02e-23,{"error":"layers[0].paint.some-property: Could not convert \"Not a number\" to number."},0,{"error":"layers[0].paint.some-property: Could not convert [1,2] to number."},{"error":"layers[0].paint.some-property: Could not convert {\"y\":1} to number."}]}},"to-rgba/alpha":{"expression":["to-rgba",["rgba",0,128,255,0.5]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[[0,128,255,0.5]]}},"to-rgba/basic":{"expression":["to-rgba",["get","x"]],"inputs":[[{},{"properties":{"x":"red"}}],[{},{"properties":{"x":"rgba(0, 255, 0, 1)"}}],[{},{"properties":{"x":[0,255,0,1]}}],[{},{"properties":{"x":[0,0,255]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[[255,0,0,1],[0,255,0,1],[0,255,0,1],[0,0,255,1]]}},"to-rgba/zero":{"expression":["to-rgba",["rgba",0,0,0,0]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"array"},"outputs":[[0,0,0,0]]}},"to-string/2-ary":{"expression":["to-string",["get","x"],["get","y"]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected one argument."}]}}},"to-string/basic":{"expression":["to-string",["get","x"]],"inputs":[[{},{"properties":{"x":1}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":null}}],[{},{"properties":{"x":"string"}}],[{},{"properties":{"x":[1,2]}}],[{},{"properties":{"x":{"y":1}}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["1","false","","string","[1,2]","{\"y\":1}"]}},"to-string/color":{"expression":["to-string",["rgba",85,255,0,0.5]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["rgba(85,255,0,0.5)"]}},"to-string/implicit":{"expression":["get","p"],"propertySpec":{"type":"string","property-type":"data-driven","expression":{"interpolated":true,"parameters":["zoom","feature"]}},"inputs":[[{},{"properties":{}}],[{},{"properties":{"p":0}}],[{},{"properties":{"p":"a"}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["","0","a"]}},"typecheck/array-invalid-item":{"propertySpec":{"type":"array","value":"string","length":2,"property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["array","number",2,["get","x"]],"inputs":[],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected array but found array instead."}]}}},"typecheck/array-item-subtyping":{"propertySpec":{"type":"array","property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["array","number",2,["get","x"]],"inputs":[],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[]}},"typecheck/array-length-subtyping":{"propertySpec":{"type":"array","value":"string","property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["array","string",2,["get","x"]],"inputs":[],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"array"},"outputs":[]}},"typecheck/array-length-subtyping--no-length":{"propertySpec":{"type":"array","value":"number","length":3,"property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["array","number",["get","x"]],"inputs":[],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected array but found array instead."}]}}},"typecheck/array-wrong-length":{"propertySpec":{"type":"array","value":"number","length":3,"property-type":"data-driven","expression":{"parameters":["zoom","feature"]}},"expression":["array","number",2,["get","x"]],"inputs":[],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected array but found array instead."}]}}},"typeof/basic":{"expression":["typeof",["get","x"]],"inputs":[[{},{"properties":{"x":null}}],[{},{"properties":{"x":"s"}}],[{},{"properties":{"x":0}}],[{},{"properties":{"x":false}}],[{},{"properties":{"x":[1,2,3]}}],[{},{"properties":{"x":["a","b","c"]}}],[{},{"properties":{"x":[true,false]}}],[{},{"properties":{"x":[1,false]}}],[{},{"properties":{"x":{}}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"string"},"outputs":["null","string","number","boolean","array","array","array","array","object"]}},"typeof/expression-value":{"expression":["typeof",["concat","foo",["to-string",0]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["string"]}},"typeof/literal-value":{"expression":["typeof",true],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["boolean"]}},"typeof/missing-value":{"expression":["typeof"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected arguments of type (value), but found () instead."}]}}},"typeof/unexpected-second-arg":{"expression":["typeof",true,42],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Expected arguments of type (value), but found (boolean, number) instead."}]}}},"upcase/basic":{"expression":["upcase","string"],"inputs":[[{},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":true,"type":"string"},"outputs":["STRING"]}},"within/expression-geojson":{"expression":["within",["get","geojson"]],"inputs":[[{},{"properties":{"geojson":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,0],[0,0]]]}}}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"'within' expression requires valid geojson object that contains polygon geometry type."}]}}},"within/invalid-geojson":{"expression":["within",{"type":"LineString","coordinates":[[0,0],[0,5],[5,5],[5,0]]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[6,6]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[2,2]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[3,3],[4,1]]}}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"'within' expression requires valid geojson object that contains polygon geometry type."}]}}},"within/line-within-collection-of-multipolygons":{"expression":["within",{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[10,0],[10,5],[15,5],[15,0],[10,0]]],[[[10,10],[10,15],[15,15],[15,10],[10,10]]]]}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[0,0],[0,5],[5,5],[5,0],[0,0]]],[[[0,10],[0,15],[5,15],[5,10],[0,10]]]]}}]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[1,1],[4,4]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[3,3],[6,6]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[11,1],[14,4]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[14,14],[11,12]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[3,3],[13,13]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false,true,true,false]}},"within/line-within-collection-of-polygons":{"expression":["within",{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10,0],[10,5],[15,5],[15,0],[10,0]]]}},{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]]]}}]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[1,1],[4,4]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[3,3],[6,6]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[11,1],[14,4]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[11,1],[16,4]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[3,3],[13,3]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false,true,false,false]}},"within/line-within-polygon":{"expression":["within",{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]]]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[3,3],[4,1]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[3,3],[6,6]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false]}},"within/line-within-polygons":{"expression":["within",{"type":"MultiPolygon","coordinates":[[[[0,0],[0,5],[5,5],[5,0],[0,0]]],[[[0,0],[-3,0],[-3,-3],[0,-3],[0,0]]]]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[3,3],[4,1]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[3,3],[-2,-2]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[0,0],[2,2]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[1,3],[-2,-2]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"LineString","coordinates":[[-1,-1],[-2,-2]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false,false,false,true]}},"within/lines-within-polygon":{"expression":["within",{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]]]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"MultiLineString","coordinates":[[[3,3],[4,1]],[[-2,-2],[-1,-1]]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"MultiLineString","coordinates":[[[3,3],[2,2]],[[1,1],[2,2],[3,2]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true]}},"within/lines-within-polygons":{"expression":["within",{"type":"MultiPolygon","coordinates":[[[[0,0],[0,5],[5,5],[5,0],[0,0]]],[[[0,0],[-3,0],[-3,-3],[0,-3],[0,0]]]]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"MultiLineString","coordinates":[[[3,3],[4,1]],[[-2,-2],[-1,-1]]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"MultiLineString","coordinates":[[[3,3],[2,2]],[[1,1],[2,2],[3,2]]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"MultiLineString","coordinates":[[[3,3],[2,2]],[[-1,1],[2,2],[3,2]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,false]}},"within/meridian-issue-line-1":{"expression":["within",{"type":"Polygon","coordinates":[[[-190,0],[-178,0],[-178,10],[-190,10],[-190,0]]]}],"inputs":[[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"LineString","coordinates":[[-183,5],[-179,1]]}}],[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"LineString","coordinates":[[-183,5],[181,1]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false]}},"within/meridian-issue-line-2":{"expression":["within",{"type":"Polygon","coordinates":[[[-185,60],[175,60],[175,65],[-185,65],[-185,60]]]}],"inputs":[[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"LineString","coordinates":[[-183,61],[-179,62]]}}],[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"LineString","coordinates":[[-183,61],[181,63]]}}],[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"LineString","coordinates":[[-183,62],[55,63.5]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,false,true]}},"within/meridian-issue-point-1":{"expression":["within",{"type":"Polygon","coordinates":[[[-185,60],[-175,60],[-175,65],[-185,65],[-185,60]]]}],"inputs":[[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"MultiPoint","coordinates":[[-183,62],[-179,63]]}}],[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"MultiPoint","coordinates":[[-183,62],[181,63]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":7,"y":2}},{"geometry":{"type":"Point","coordinates":[177,62.5]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,true]}},"within/meridian-issue-point-2":{"expression":["within",{"type":"Polygon","coordinates":[[[-360,60],[0,60],[0,65],[-360,65],[-360,60]]]}],"inputs":[[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"Point","coordinates":[-183,61]}}],[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"Point","coordinates":[-361,61]}}],[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"Point","coordinates":[183,62]}}],[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"Point","coordinates":[55,62]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[true,true,true,true]}},"within/missing-geojson":{"expression":["within"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"'within' expression requires exactly one argument, but found 0 instead."}]}}},"within/non-supported":{"expression":["within",{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]]]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Polygon","coordinates":[[[3,3],[6,6],[3,3]]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false]}},"within/point-on-boundary-1":{"expression":["within",{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]]]}],"inputs":[[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":1,"canonicalID":{"z":1,"x":1,"y":1}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":2,"canonicalID":{"z":2,"x":2,"y":2}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":4,"canonicalID":{"z":4,"x":1,"y":1}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":5,"canonicalID":{"z":5,"x":16,"y":16}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":6,"canonicalID":{"z":6,"x":32,"y":32}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":7,"canonicalID":{"z":7,"x":65,"y":63}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":8,"canonicalID":{"z":8,"x":131,"y":124}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":9,"canonicalID":{"z":9,"x":263,"y":248}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":10,"canonicalID":{"z":10,"x":526,"y":497}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":11,"canonicalID":{"z":11,"x":1052,"y":995}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":12,"canonicalID":{"z":12,"x":2104,"y":1991}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":13,"canonicalID":{"z":13,"x":4209,"y":3982}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":14,"canonicalID":{"z":14,"x":8419,"y":7964}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":15,"canonicalID":{"z":15,"x":16839,"y":15928}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":16,"canonicalID":{"z":16,"x":33678,"y":31856}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":17,"canonicalID":{"z":17,"x":67356,"y":67313}},{"geometry":{"type":"Point","coordinates":[5,5]}}],[{"zoom":18,"canonicalID":{"z":18,"x":134712,"y":127426}},{"geometry":{"type":"Point","coordinates":[5,5]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false]}},"within/point-on-boundary-2":{"expression":["within",{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]]]}],"inputs":[[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":1,"canonicalID":{"z":1,"x":1,"y":1}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":2,"canonicalID":{"z":2,"x":2,"y":2}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":4,"canonicalID":{"z":4,"x":1,"y":1}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":5,"canonicalID":{"z":5,"x":16,"y":16}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":6,"canonicalID":{"z":6,"x":32,"y":32}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":7,"canonicalID":{"z":7,"x":65,"y":63}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":8,"canonicalID":{"z":8,"x":131,"y":126}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":9,"canonicalID":{"z":9,"x":261,"y":261}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":10,"canonicalID":{"z":10,"x":512,"y":503}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":11,"canonicalID":{"z":11,"x":1024,"y":1006}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":12,"canonicalID":{"z":12,"x":2048,"y":2013}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":13,"canonicalID":{"z":13,"x":4096,"y":4027}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":14,"canonicalID":{"z":14,"x":8192,"y":8055}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":15,"canonicalID":{"z":15,"x":16384,"y":16110}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":16,"canonicalID":{"z":16,"x":32768,"y":32221}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":17,"canonicalID":{"z":17,"x":65536,"y":64443}},{"geometry":{"type":"Point","coordinates":[0,3]}}],[{"zoom":18,"canonicalID":{"z":18,"x":131072,"y":128888}},{"geometry":{"type":"Point","coordinates":[0,3]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false]}},"within/point-on-boundary-3":{"expression":["within",{"type":"Polygon","coordinates":[[[-10,-10],[10,-10],[10,10],[-10,10],[-10,-10]]]}],"inputs":[[{"zoom":0,"canonicalID":{"z":0,"x":0,"y":0}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":1,"canonicalID":{"z":1,"x":1,"y":1}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":2,"canonicalID":{"z":2,"x":2,"y":2}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":4,"canonicalID":{"z":4,"x":1,"y":1}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":5,"canonicalID":{"z":5,"x":16,"y":16}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":6,"canonicalID":{"z":6,"x":32,"y":32}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":7,"canonicalID":{"z":7,"x":65,"y":63}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":8,"canonicalID":{"z":8,"x":135,"y":135}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":9,"canonicalID":{"z":9,"x":270,"y":270}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":10,"canonicalID":{"z":10,"x":540,"y":540}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":11,"canonicalID":{"z":11,"x":1080,"y":1081}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":12,"canonicalID":{"z":12,"x":2161,"y":2162}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":13,"canonicalID":{"z":13,"x":4323,"y":4324}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":14,"canonicalID":{"z":14,"x":8650,"y":8650}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":15,"canonicalID":{"z":15,"x":17295,"y":17298}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":16,"canonicalID":{"z":16,"x":34588,"y":34597}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":17,"canonicalID":{"z":17,"x":69176,"y":69195}},{"geometry":{"type":"Point","coordinates":[10,-10]}}],[{"zoom":18,"canonicalID":{"z":18,"x":138353,"y":138391}},{"geometry":{"type":"Point","coordinates":[10,-10]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false]}},"within/point-within-collection-of-multipolygons":{"expression":["within",{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[10,0],[10,5],[15,5],[15,0],[10,0]]],[[[110,0],[110,5],[115,5],[115,0],[110,0]]]]}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[0,0],[0,5],[5,5],[5,0],[0,0]]],[[[50,0],[50,5],[55,5],[55,0],[50,0]]]]}}]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[6,6]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[2,2]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[12,2]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[22,2]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[52,2]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[112,2]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,true,false,true,true]}},"within/point-within-collection-of-polygons":{"expression":["within",{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10,0],[10,5],[15,5],[15,0],[10,0]]]}},{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]]]}}]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[6,6]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[2,2]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[12,2]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,true]}},"within/point-within-polygon":{"expression":["within",{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]]]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[6,6]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[2,2]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true]}},"within/point-within-polygons":{"expression":["within",{"type":"MultiPolygon","coordinates":[[[[0,0],[0,5],[5,5],[5,0],[0,0]]],[[[0,0],[-3,0],[-3,-3],[0,-3],[0,0]]]]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[6,6]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[2,2]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[3,4]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[-1,3]}}],[{"zoom":3,"canonicalID":{"z":3,"x":0,"y":1}},{"geometry":{"type":"Point","coordinates":[-1,-5]}}],[{"zoom":3,"canonicalID":{"z":5,"x":3,"y":3}},{"geometry":{"type":"Point","coordinates":[2,-2]}}],[{"zoom":3,"canonicalID":{"z":2,"x":0,"y":1}},{"geometry":{"type":"Point","coordinates":[-2,-2]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,true,false,false,false,true]}},"within/points-within-polygon":{"expression":["within",{"type":"Polygon","coordinates":[[[0,0],[0,5],[5,5],[5,0],[0,0]]]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"MultiPoint","coordinates":[[6,6],[3,4],[2,2]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"MultiPoint","coordinates":[[2,2],[3,3]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true]}},"within/points-within-polygons":{"expression":["within",{"type":"MultiPolygon","coordinates":[[[[0,0],[0,5],[5,5],[5,0],[0,0]]],[[[0,0],[-3,0],[-3,-3],[0,-3],[0,0]]]]}],"inputs":[[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"MultiPoint","coordinates":[[6,6],[2,2]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"MultiPoint","coordinates":[[2,2],[3,3]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"MultiPoint","coordinates":[[3,4],[-2,-2]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":3,"y":3}},{"geometry":{"type":"MultiPoint","coordinates":[[-1,3],[1,3]]}}],[{"zoom":3,"canonicalID":{"z":3,"x":0,"y":1}},{"geometry":{"type":"MultiPoint","coordinates":[[2,3],[-2,3],[-2,-2]]}}],[{"zoom":3,"canonicalID":{"z":5,"x":15,"y":16}},{"geometry":{"type":"MultiPoint","coordinates":[[-2,-2],[-1,-1]]}}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":true,"type":"boolean"},"outputs":[false,true,true,false,false,true]}},"within/unexpected-second-arg":{"expression":["within",{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,0],[0,0]]]},"second arg"],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"'within' expression requires exactly one argument, but found 2 instead."}]}}},"zoom/basic":{"expression":["interpolate",["linear"],["zoom"],0,0,30,30],"inputs":[[{"zoom":5},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"number"},"outputs":[5]}},"zoom/invalid-nested-1":{"expression":["+",0.5,["interpolate",["linear"],["zoom"],0,0,1,1]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"\"zoom\" expression may only be used as input to a top-level \"step\" or \"interpolate\" expression."}]}}},"zoom/invalid-nested-2":{"expression":["interpolate",["linear"],["zoom"],0,0,1,["interpolate",["linear"],["zoom"],0,0,1,1]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Only one zoom-based \"step\" or \"interpolate\" subexpression may be used in an expression."}]}}},"zoom/invalid-nested-3":{"expression":["let","x",["interpolate",["linear"],["zoom"],0,0,1,1],["interpolate",["linear"],["zoom"],0,0,1,1]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Only one zoom-based \"step\" or \"interpolate\" subexpression may be used in an expression."}]}}},"zoom/invalid-nested-4":{"expression":["coalesce",["interpolate",["linear"],["zoom"],0,0,1,1],["interpolate",["linear"],["zoom"],0,0,1,1]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"Only one zoom-based \"step\" or \"interpolate\" subexpression may be used in an expression."}]}}},"zoom/invalid-nested-5":{"expression":["let","x",["interpolate",["linear"],["zoom"],0,0,1,1],["+",0.5,["get","x"]]],"inputs":[[{},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"\"zoom\" expression may only be used as input to a top-level \"step\" or \"interpolate\" expression."}]}}},"zoom/invalid-no-curve":{"expression":["+",["zoom"],0],"inputs":[[{"zoom":5},{}]],"expected":{"compiled":{"result":"error","errors":[{"key":"","error":"\"zoom\" expression may only be used as input to a top-level \"step\" or \"interpolate\" expression."}]}}},"zoom/nested-coalesce":{"expression":["coalesce",["interpolate",["linear"],["zoom"],0,0,30,30]],"inputs":[[{"zoom":5},{}]],"expected":{"compiled":{"result":"success","isFeatureConstant":true,"isZoomConstant":false,"type":"number"},"outputs":[5]}},"zoom/nested-let":{"expression":["let","x",["number",["get","a"]],["interpolate",["linear"],["zoom"],0,0,30,["var","x"]]],"inputs":[[{"zoom":5},{"a":30}]],"expected":{"compiled":{"result":"success","isFeatureConstant":false,"isZoomConstant":false,"type":"number"},"outputs":[{"error":"layers[0].paint.some-property[2]: Expected value to be of type number, but found null instead."}]}}}} \ No newline at end of file diff --git a/library/src/commonTest/composeResources/files/test_style_bright.json b/library/src/commonTest/composeResources/files/test_style_bright.json new file mode 100644 index 00000000..95dad4bb --- /dev/null +++ b/library/src/commonTest/composeResources/files/test_style_bright.json @@ -0,0 +1,2565 @@ +{ + "version": 8, + "name": "Bright", + "metadata": { + "mapbox:autocomposite": false, + "mapbox:groups": { + "1444849242106.713": {"collapsed": false, "name": "Places"}, + "1444849334699.1902": {"collapsed": true, "name": "Bridges"}, + "1444849345966.4436": {"collapsed": false, "name": "Roads"}, + "1444849354174.1904": {"collapsed": true, "name": "Tunnels"}, + "1444849364238.8171": {"collapsed": false, "name": "Buildings"}, + "1444849382550.77": {"collapsed": false, "name": "Water"}, + "1444849388993.3071": {"collapsed": false, "name": "Land"} + }, + "mapbox:type": "template", + "openmaptiles:mapbox:owner": "openmaptiles", + "openmaptiles:mapbox:source:url": "mapbox://openmaptiles.4qljc88t", + "openmaptiles:version": "3.x" + }, + "center": [0, 0], + "zoom": 1, + "bearing": 0, + "pitch": 0, + "sources": { + "openmaptiles": { + "tiles": [ + "https://api.maptiler.com/tiles/v3-openmaptiles/{z}/{x}/{y}.pbf?key=XXX" + ], + "minzoom": 0, + "maxzoom": 14, + "attribution": "© MapTiler © OpenStreetMap contributors", + "type": "vector" + } + }, + "sprite": "https://openmaptiles.github.io/osm-bright-gl-style/sprite", + "glyphs": "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key={key}", + "layers": [ + { + "id": "background", + "type": "background", + "paint": {"background-color": "#f8f4f0"} + }, + { + "id": "landcover-glacier", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "openmaptiles", + "source-layer": "landcover", + "filter": ["==", "subclass", "glacier"], + "layout": {"visibility": "visible"}, + "paint": { + "fill-color": "#fff", + "fill-opacity": {"base": 1, "stops": [[0, 0.9], [10, 0.3]]} + } + }, + { + "id": "landuse-residential", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "openmaptiles", + "source-layer": "landuse", + "filter": [ + "all", + ["in", "class", "residential", "suburb", "neighbourhood"] + ], + "layout": {"visibility": "visible"}, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [12, "hsla(30, 19%, 90%, 0.4)"], + [16, "hsla(30, 19%, 90%, 0.2)"] + ] + } + } + }, + { + "id": "landuse-commercial", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "openmaptiles", + "source-layer": "landuse", + "filter": [ + "all", + ["==", "$type", "Polygon"], + ["==", "class", "commercial"] + ], + "layout": {"visibility": "visible"}, + "paint": {"fill-color": "hsla(0, 60%, 87%, 0.23)"} + }, + { + "id": "landuse-industrial", + "type": "fill", + "source": "openmaptiles", + "source-layer": "landuse", + "filter": [ + "all", + ["==", "$type", "Polygon"], + ["in", "class", "industrial", "garages", "dam"] + ], + "layout": {"visibility": "visible"}, + "paint": {"fill-color": "hsla(49, 100%, 88%, 0.34)"} + }, + { + "id": "landuse-cemetery", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "openmaptiles", + "source-layer": "landuse", + "filter": ["==", "class", "cemetery"], + "paint": {"fill-color": "#e0e4dd"} + }, + { + "id": "landuse-hospital", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "openmaptiles", + "source-layer": "landuse", + "filter": ["==", "class", "hospital"], + "paint": {"fill-color": "#fde"} + }, + { + "id": "landuse-school", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "openmaptiles", + "source-layer": "landuse", + "filter": ["==", "class", "school"], + "paint": {"fill-color": "#f0e8f8"} + }, + { + "id": "landuse-railway", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "openmaptiles", + "source-layer": "landuse", + "filter": ["==", "class", "railway"], + "layout": {"visibility": "visible"}, + "paint": {"fill-color": "hsla(30, 19%, 90%, 0.4)"} + }, + { + "id": "landcover-wood", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "openmaptiles", + "source-layer": "landcover", + "filter": ["==", "class", "wood"], + "paint": { + "fill-antialias": {"base": 1, "stops": [[0, false], [9, true]]}, + "fill-color": "#6a4", + "fill-opacity": 0.1, + "fill-outline-color": "hsla(0, 0%, 0%, 0.03)" + } + }, + { + "id": "landcover-grass", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "openmaptiles", + "source-layer": "landcover", + "filter": ["==", "class", "grass"], + "paint": {"fill-color": "#d8e8c8", "fill-opacity": 1} + }, + { + "id": "landcover-grass-park", + "type": "fill", + "metadata": {"mapbox:group": "1444849388993.3071"}, + "source": "openmaptiles", + "source-layer": "park", + "filter": ["==", "class", "public_park"], + "paint": {"fill-color": "#d8e8c8", "fill-opacity": 0.8} + }, + { + "id": "waterway_tunnel", + "type": "line", + "source": "openmaptiles", + "source-layer": "waterway", + "minzoom": 14, + "filter": [ + "all", + ["in", "class", "river", "stream", "canal"], + ["==", "brunnel", "tunnel"] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [2, 4], + "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 6]]} + } + }, + { + "id": "waterway-other", + "type": "line", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["!in", "class", "canal", "river", "stream"], + ["==", "intermittent", 0] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 2]]} + } + }, + { + "id": "waterway-other-intermittent", + "type": "line", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["!in", "class", "canal", "river", "stream"], + ["==", "intermittent", 1] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [4, 3], + "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 2]]} + } + }, + { + "id": "waterway-stream-canal", + "type": "line", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["in", "class", "canal", "stream"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 0] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 6]]} + } + }, + { + "id": "waterway-stream-canal-intermittent", + "type": "line", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["in", "class", "canal", "stream"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 1] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [4, 3], + "line-width": {"base": 1.3, "stops": [[13, 0.5], [20, 6]]} + } + }, + { + "id": "waterway-river", + "type": "line", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["==", "class", "river"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 0] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-width": {"base": 1.2, "stops": [[10, 0.8], [20, 6]]} + } + }, + { + "id": "waterway-river-intermittent", + "type": "line", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "openmaptiles", + "source-layer": "waterway", + "filter": [ + "all", + ["==", "class", "river"], + ["!=", "brunnel", "tunnel"], + ["==", "intermittent", 1] + ], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [3, 2.5], + "line-width": {"base": 1.2, "stops": [[10, 0.8], [20, 6]]} + } + }, + { + "id": "water-offset", + "type": "fill", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "openmaptiles", + "source-layer": "water", + "maxzoom": 8, + "filter": ["==", "$type", "Polygon"], + "layout": {"visibility": "visible"}, + "paint": { + "fill-color": "#a0c8f0", + "fill-opacity": 1, + "fill-translate": {"base": 1, "stops": [[6, [2, 0]], [8, [0, 0]]]} + } + }, + { + "id": "water", + "type": "fill", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "openmaptiles", + "source-layer": "water", + "filter": ["all", ["!=", "intermittent", 1], ["!=", "brunnel", "tunnel"]], + "layout": {"visibility": "visible"}, + "paint": {"fill-color": "hsl(210, 67%, 85%)"} + }, + { + "id": "water-intermittent", + "type": "fill", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "openmaptiles", + "source-layer": "water", + "filter": ["all", ["==", "intermittent", 1]], + "layout": {"visibility": "visible"}, + "paint": {"fill-color": "hsl(210, 67%, 85%)", "fill-opacity": 0.7} + }, + { + "id": "water-pattern", + "type": "fill", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "openmaptiles", + "source-layer": "water", + "filter": ["all"], + "layout": {"visibility": "visible"}, + "paint": {"fill-pattern": "wave", "fill-translate": [0, 2.5]} + }, + { + "id": "landcover-ice-shelf", + "type": "fill", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "openmaptiles", + "source-layer": "landcover", + "filter": ["==", "subclass", "ice_shelf"], + "layout": {"visibility": "visible"}, + "paint": { + "fill-color": "#fff", + "fill-opacity": {"base": 1, "stops": [[0, 0.9], [10, 0.3]]} + } + }, + { + "id": "landcover-sand", + "type": "fill", + "metadata": {"mapbox:group": "1444849382550.77"}, + "source": "openmaptiles", + "source-layer": "landcover", + "filter": ["==", "class", "sand"], + "layout": {"visibility": "visible"}, + "paint": {"fill-color": "rgba(245, 238, 188, 1)", "fill-opacity": 1} + }, + { + "id": "building", + "type": "fill", + "metadata": {"mapbox:group": "1444849364238.8171"}, + "source": "openmaptiles", + "source-layer": "building", + "paint": { + "fill-antialias": true, + "fill-color": {"base": 1, "stops": [[15.5, "#f2eae2"], [16, "#dfdbd7"]]} + } + }, + { + "id": "building-top", + "type": "fill", + "metadata": {"mapbox:group": "1444849364238.8171"}, + "source": "openmaptiles", + "source-layer": "building", + "layout": {"visibility": "visible"}, + "paint": { + "fill-color": "#f2eae2", + "fill-opacity": {"base": 1, "stops": [[13, 0], [16, 1]]}, + "fill-outline-color": "#dfdbd7", + "fill-translate": {"base": 1, "stops": [[14, [0, 0]], [16, [-2, -2]]]} + } + }, + { + "id": "tunnel-service-track-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "service", "track"] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#cfcdca", + "line-dasharray": [0.5, 0.25], + "line-width": {"base": 1.2, "stops": [[15, 1], [16, 4], [20, 11]]} + } + }, + { + "id": "tunnel-motorway-link-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["==", "class", "motorway"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round", "visibility": "visible"}, + "paint": { + "line-color": "rgba(200, 147, 102, 1)", + "line-dasharray": [0.5, 0.25], + "line-width": { + "base": 1.2, + "stops": [[12, 1], [13, 3], [14, 4], [20, 15]] + } + } + }, + { + "id": "tunnel-minor-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "minor"]], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#cfcdca", + "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[12, 0.5], [13, 1], [14, 4], [20, 15]] + }, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "tunnel-link-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "trunk", "primary", "secondary", "tertiary"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[12, 1], [13, 3], [14, 4], [20, 15]] + }, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "tunnel-secondary-tertiary-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "secondary", "tertiary"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": {"base": 1.2, "stops": [[8, 1.5], [20, 17]]}, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "tunnel-trunk-primary-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "primary", "trunk"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-width": { + "base": 1.2, + "stops": [[5, 0.4], [6, 0.6], [7, 1.5], [20, 22]] + } + } + }, + { + "id": "tunnel-motorway-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["==", "class", "motorway"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round", "visibility": "visible"}, + "paint": { + "line-color": "#e9ac77", + "line-dasharray": [0.5, 0.25], + "line-width": { + "base": 1.2, + "stops": [[5, 0.4], [6, 0.6], [7, 1.5], [20, 22]] + } + } + }, + { + "id": "tunnel-path-steps-casing", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "tunnel"], + ["==", "class", "path"], + ["==", "subclass", "steps"] + ], + "layout": {"line-cap": "butt", "line-join": "round"}, + "paint": { + "line-color": "#cfcdca", + "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[12, 0.5], [13, 1], [14, 2], [20, 9.25]] + }, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "tunnel-path-steps", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "tunnel"], + ["==", "class", "path"], + ["==", "subclass", "steps"] + ], + "layout": {"line-join": "bevel", "line-cap": "butt"}, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[13.5, 0], [14, 1.25], [20, 5.75]] + }, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "tunnel-path", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "tunnel"], + ["==", "class", "path"], + ["!=", "subclass", "steps"] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [1.5, 0.75], + "line-width": {"base": 1.2, "stops": [[15, 1.2], [20, 4]]} + } + }, + { + "id": "tunnel-motorway-link", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["==", "class", "motorway"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round", "visibility": "visible"}, + "paint": { + "line-color": "rgba(244, 209, 158, 1)", + "line-width": { + "base": 1.2, + "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] + } + } + }, + { + "id": "tunnel-service-track", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "service", "track"] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fff", + "line-width": {"base": 1.2, "stops": [[15.5, 0], [16, 2], [20, 7.5]]} + } + }, + { + "id": "tunnel-link", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "trunk", "primary", "secondary", "tertiary"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fff4c6", + "line-width": { + "base": 1.2, + "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] + } + } + }, + { + "id": "tunnel-minor", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "minor"]], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": {"base": 1.2, "stops": [[13.5, 0], [14, 2.5], [20, 11.5]]} + } + }, + { + "id": "tunnel-secondary-tertiary", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "secondary", "tertiary"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fff4c6", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 10]]} + } + }, + { + "id": "tunnel-trunk-primary", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["in", "class", "primary", "trunk"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fff4c6", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} + } + }, + { + "id": "tunnel-motorway", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "tunnel"], + ["==", "class", "motorway"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round", "visibility": "visible"}, + "paint": { + "line-color": "#ffdaa6", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} + } + }, + { + "id": "tunnel-railway", + "type": "line", + "metadata": {"mapbox:group": "1444849354174.1904"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "rail"]], + "paint": { + "line-color": "#bbb", + "line-dasharray": [2, 2], + "line-width": {"base": 1.4, "stops": [[14, 0.4], [15, 0.75], [20, 2]]} + } + }, + { + "id": "ferry", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["in", "class", "ferry"]], + "layout": {"line-join": "round", "visibility": "visible"}, + "paint": { + "line-color": "rgba(108, 159, 182, 1)", + "line-dasharray": [2, 2], + "line-width": 1.1 + } + }, + { + "id": "aeroway-taxiway-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 12, + "filter": ["all", ["in", "class", "taxiway"]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(153, 153, 153, 1)", + "line-opacity": 1, + "line-width": {"base": 1.5, "stops": [[11, 2], [17, 12]]} + } + }, + { + "id": "aeroway-runway-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 12, + "filter": ["all", ["in", "class", "runway"]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(153, 153, 153, 1)", + "line-opacity": 1, + "line-width": {"base": 1.5, "stops": [[11, 5], [17, 55]]} + } + }, + { + "id": "aeroway-area", + "type": "fill", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + ["==", "$type", "Polygon"], + ["in", "class", "runway", "taxiway"] + ], + "layout": {"visibility": "visible"}, + "paint": { + "fill-color": "rgba(255, 255, 255, 1)", + "fill-opacity": {"base": 1, "stops": [[13, 0], [14, 1]]} + } + }, + { + "id": "aeroway-taxiway", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + ["in", "class", "taxiway"], + ["==", "$type", "LineString"] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": {"base": 1, "stops": [[11, 0], [12, 1]]}, + "line-width": {"base": 1.5, "stops": [[11, 1], [17, 10]]} + } + }, + { + "id": "aeroway-runway", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + ["in", "class", "runway"], + ["==", "$type", "LineString"] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-opacity": {"base": 1, "stops": [[11, 0], [12, 1]]}, + "line-width": {"base": 1.5, "stops": [[11, 4], [17, 50]]} + } + }, + { + "id": "road_area_pier", + "type": "fill", + "metadata": {}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "$type", "Polygon"], ["==", "class", "pier"]], + "layout": {"visibility": "visible"}, + "paint": {"fill-antialias": true, "fill-color": "#f8f4f0"} + }, + { + "id": "road_pier", + "type": "line", + "metadata": {}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "$type", "LineString"], ["in", "class", "pier"]], + "layout": {"line-cap": "round", "line-join": "round"}, + "paint": { + "line-color": "#f8f4f0", + "line-width": {"base": 1.2, "stops": [[15, 1], [17, 4]]} + } + }, + { + "id": "highway-area", + "type": "fill", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "$type", "Polygon"], ["!in", "class", "pier"]], + "layout": {"visibility": "visible"}, + "paint": { + "fill-antialias": false, + "fill-color": "hsla(0, 0%, 89%, 0.56)", + "fill-opacity": 0.9, + "fill-outline-color": "#cfcdca" + } + }, + { + "id": "highway-path-steps-casing", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "path"], + ["in", "subclass", "steps"] + ], + "layout": {"line-cap": "butt", "line-join": "round"}, + "paint": { + "line-color": "#cfcdca", + "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[12, 0.5], [13, 1], [14, 2], [20, 9.25]] + } + } + }, + { + "id": "highway-motorway-link-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 12, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway"], + ["==", "ramp", 1] + ], + "layout": {"line-cap": "round", "line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[12, 1], [13, 3], [14, 4], [20, 15]] + } + } + }, + { + "id": "highway-link-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "trunk", "primary", "secondary", "tertiary"], + ["==", "ramp", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[12, 1], [13, 3], [14, 4], [20, 15]] + } + } + }, + { + "id": "highway-minor-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!=", "brunnel", "tunnel"], + ["in", "class", "minor", "service", "track"] + ], + "layout": {"line-cap": "round", "line-join": "round"}, + "paint": { + "line-color": "#cfcdca", + "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[12, 0.5], [13, 1], [14, 4], [20, 15]] + } + } + }, + { + "id": "highway-secondary-tertiary-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "secondary", "tertiary"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": {"base": 1.2, "stops": [[8, 1.5], [20, 17]]} + } + }, + { + "id": "highway-primary-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "primary"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": {"stops": [[7, 0], [8, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[7, 0], [8, 0.6], [9, 1.5], [20, 22]] + } + } + }, + { + "id": "highway-trunk-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "trunk"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": {"stops": [[5, 0], [6, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[5, 0], [6, 0.6], [7, 1.5], [20, 22]] + } + } + }, + { + "id": "highway-motorway-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 4, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": {"stops": [[4, 0], [5, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[4, 0], [5, 0.4], [6, 0.6], [7, 1.5], [20, 22]] + } + } + }, + { + "id": "highway-path", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "path"], + ["!=", "subclass", "steps"] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [1.5, 0.75], + "line-width": {"base": 1.2, "stops": [[15, 1.2], [20, 4]]} + } + }, + { + "id": "highway-path-steps", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "path"], + ["==", "subclass", "steps"] + ], + "layout": {"line-join": "bevel", "line-cap": "butt"}, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[13.5, 0], [14, 1.25], [20, 5.75]] + }, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "highway-motorway-link", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 12, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway"], + ["==", "ramp", 1] + ], + "layout": {"line-cap": "round", "line-join": "round"}, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] + } + } + }, + { + "id": "highway-link", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "trunk", "primary", "secondary", "tertiary"], + ["==", "ramp", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] + } + } + }, + { + "id": "highway-minor", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!=", "brunnel", "tunnel"], + ["in", "class", "minor", "service", "track"] + ], + "layout": {"line-cap": "round", "line-join": "round"}, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": {"base": 1.2, "stops": [[13.5, 0], [14, 2.5], [20, 11.5]]} + } + }, + { + "id": "highway-secondary-tertiary", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "secondary", "tertiary"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [8, 0.5], [20, 13]]} + } + }, + { + "id": "highway-primary", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "primary"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": {"base": 1.2, "stops": [[8.5, 0], [9, 0.5], [20, 18]]} + } + }, + { + "id": "highway-trunk", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!in", "brunnel", "bridge", "tunnel"], + ["in", "class", "trunk"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} + } + }, + { + "id": "highway-motorway", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 5, + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "motorway"], + ["!=", "ramp", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fc8", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} + } + }, + { + "id": "railway-transit", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "class", "transit"], + ["!in", "brunnel", "tunnel"] + ], + "layout": {"visibility": "visible"}, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.77)", + "line-width": {"base": 1.4, "stops": [[14, 0.4], [20, 1]]} + } + }, + { + "id": "railway-transit-hatching", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "class", "transit"], + ["!in", "brunnel", "tunnel"] + ], + "layout": {"visibility": "visible"}, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.68)", + "line-dasharray": [0.2, 8], + "line-width": {"base": 1.4, "stops": [[14.5, 0], [15, 2], [20, 6]]} + } + }, + { + "id": "railway-service", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "class", "rail"], + ["has", "service"] + ], + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.77)", + "line-width": {"base": 1.4, "stops": [[14, 0.4], [20, 1]]} + } + }, + { + "id": "railway-service-hatching", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "class", "rail"], + ["has", "service"] + ], + "layout": {"visibility": "visible"}, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.68)", + "line-dasharray": [0.2, 8], + "line-width": {"base": 1.4, "stops": [[14.5, 0], [15, 2], [20, 6]]} + } + }, + { + "id": "railway", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!has", "service"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "rail"] + ], + "paint": { + "line-color": "#bbb", + "line-width": {"base": 1.4, "stops": [[14, 0.4], [15, 0.75], [20, 2]]} + } + }, + { + "id": "railway-hatching", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["!has", "service"], + ["!in", "brunnel", "bridge", "tunnel"], + ["==", "class", "rail"] + ], + "paint": { + "line-color": "#bbb", + "line-dasharray": [0.2, 8], + "line-width": {"base": 1.4, "stops": [[14.5, 0], [15, 3], [20, 8]]} + } + }, + { + "id": "bridge-motorway-link-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["==", "class", "motorway"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[12, 1], [13, 3], [14, 4], [20, 19]] + } + } + }, + { + "id": "bridge-link-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "trunk", "primary", "secondary", "tertiary"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[12, 1], [13, 3], [14, 4], [20, 19]] + } + } + }, + { + "id": "bridge-secondary-tertiary-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "secondary", "tertiary"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[5, 0.4], [7, 0.6], [8, 1.5], [20, 21]] + } + } + }, + { + "id": "bridge-trunk-primary-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "primary", "trunk"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "hsl(28, 76%, 67%)", + "line-width": { + "base": 1.2, + "stops": [[5, 0.4], [6, 0.6], [7, 1.5], [20, 26]] + } + } + }, + { + "id": "bridge-motorway-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["==", "class", "motorway"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#e9ac77", + "line-width": { + "base": 1.2, + "stops": [[5, 0.4], [6, 0.6], [7, 1.5], [20, 26]] + } + } + }, + { + "id": "bridge-minor-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "bridge"], + ["in", "class", "minor", "service", "track"] + ], + "layout": {"line-cap": "butt", "line-join": "round"}, + "paint": { + "line-color": "#cfcdca", + "line-opacity": {"stops": [[12, 0], [12.5, 1]]}, + "line-width": { + "base": 1.2, + "stops": [[12, 0.5], [13, 1], [14, 6], [20, 24]] + } + } + }, + { + "id": "bridge-path-casing", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "bridge"], + ["==", "class", "path"] + ], + "paint": { + "line-color": "#cfcdca", + "line-width": {"base": 1.2, "stops": [[15, 1.2], [20, 18]]} + } + }, + { + "id": "bridge-path-steps", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "bridge"], + ["==", "class", "path"], + ["==", "subclass", "steps"] + ], + "layout": {"line-join": "round", "line-cap": "butt"}, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [[13.5, 0], [14, 1.25], [20, 5.75]] + }, + "line-dasharray": [0.5, 0.25] + } + }, + { + "id": "bridge-path", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "bridge"], + ["==", "class", "path"], + ["!=", "subclass", "steps"] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [1.5, 0.75], + "line-width": {"base": 1.2, "stops": [[15, 1.2], [20, 4]]} + } + }, + { + "id": "bridge-motorway-link", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["==", "class", "motorway"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] + } + } + }, + { + "id": "bridge-link", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "trunk", "primary", "secondary", "tertiary"], + ["==", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [[12.5, 0], [13, 1.5], [14, 2.5], [20, 11.5]] + } + } + }, + { + "id": "bridge-minor", + "type": "line", + "metadata": {"mapbox:group": "1444849345966.4436"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "$type", "LineString"], + ["==", "brunnel", "bridge"], + ["in", "class", "minor", "service", "track"] + ], + "layout": {"line-cap": "round", "line-join": "round"}, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": {"base": 1.2, "stops": [[13.5, 0], [14, 2.5], [20, 11.5]]} + } + }, + { + "id": "bridge-secondary-tertiary", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "secondary", "tertiary"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fea", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [8, 0.5], [20, 13]]} + } + }, + { + "id": "bridge-trunk-primary", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["in", "class", "primary", "trunk"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fea", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} + } + }, + { + "id": "bridge-motorway", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": [ + "all", + ["==", "brunnel", "bridge"], + ["==", "class", "motorway"], + ["!=", "ramp", 1] + ], + "layout": {"line-join": "round"}, + "paint": { + "line-color": "#fc8", + "line-width": {"base": 1.2, "stops": [[6.5, 0], [7, 0.5], [20, 18]]} + } + }, + { + "id": "bridge-railway", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "rail"]], + "paint": { + "line-color": "#bbb", + "line-width": {"base": 1.4, "stops": [[14, 0.4], [15, 0.75], [20, 2]]} + } + }, + { + "id": "bridge-railway-hatching", + "type": "line", + "metadata": {"mapbox:group": "1444849334699.1902"}, + "source": "openmaptiles", + "source-layer": "transportation", + "filter": ["all", ["==", "brunnel", "bridge"], ["==", "class", "rail"]], + "paint": { + "line-color": "#bbb", + "line-dasharray": [0.2, 8], + "line-width": {"base": 1.4, "stops": [[14.5, 0], [15, 3], [20, 8]]} + } + }, + { + "id": "cablecar", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": ["==", "subclass", "cable_car"], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "hsl(0, 0%, 70%)", + "line-width": {"base": 1, "stops": [[11, 1], [19, 2.5]]} + } + }, + { + "id": "cablecar-dash", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 13, + "filter": ["==", "subclass", "cable_car"], + "layout": {"line-cap": "round", "visibility": "visible"}, + "paint": { + "line-color": "hsl(0, 0%, 70%)", + "line-dasharray": [2, 3], + "line-width": {"base": 1, "stops": [[11, 3], [19, 5.5]]} + } + }, + { + "id": "boundary-land-level-4", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "minzoom": 2, + "filter": [ + "all", + [">=", "admin_level", 3], + ["<=", "admin_level", 8], + ["!=", "maritime", 1] + ], + "layout": {"line-join": "round", "visibility": "visible"}, + "paint": { + "line-color": "#9e9cab", + "line-dasharray": [3, 1, 1, 1], + "line-width": {"base": 1.4, "stops": [[4, 0.4], [5, 1], [12, 3]]} + } + }, + { + "id": "boundary-land-level-2", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "filter": [ + "all", + ["==", "admin_level", 2], + ["!=", "maritime", 1], + ["!=", "disputed", 1] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(248, 7%, 66%)", + "line-width": { + "base": 1, + "stops": [[0, 0.6], [4, 1.4], [5, 2], [12, 8]] + } + } + }, + { + "id": "boundary-land-disputed", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "filter": ["all", ["!=", "maritime", 1], ["==", "disputed", 1]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(248, 7%, 70%)", + "line-dasharray": [1, 3], + "line-width": { + "base": 1, + "stops": [[0, 0.6], [4, 1.4], [5, 2], [12, 8]] + } + } + }, + { + "id": "boundary-water", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "minzoom": 4, + "filter": ["all", ["in", "admin_level", 2, 4], ["==", "maritime", 1]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "rgba(154, 189, 214, 1)", + "line-opacity": {"stops": [[6, 0.6], [10, 1]]}, + "line-width": { + "base": 1, + "stops": [[0, 0.6], [4, 1.4], [5, 2], [12, 8]] + } + } + }, + { + "id": "waterway-name", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "waterway", + "minzoom": 13, + "filter": ["all", ["==", "$type", "LineString"], ["has", "name"]], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 350, + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-lakeline", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "water_name", + "filter": ["==", "$type", "LineString"], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 350, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-ocean", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "water_name", + "filter": ["all", ["==", "$type", "Point"], ["==", "class", "ocean"]], + "layout": { + "symbol-placement": "point", + "symbol-spacing": 350, + "text-field": "{name:latin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": 14 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "water-name-other", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "water_name", + "filter": ["all", ["==", "$type", "Point"], ["!in", "class", "ocean"]], + "layout": { + "symbol-placement": "point", + "symbol-spacing": 350, + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Italic"], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": {"stops": [[0, 10], [6, 14]]}, + "visibility": "visible" + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "road_oneway", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + ["==", "oneway", 1], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": 90, + "icon-rotation-alignment": "map", + "icon-size": {"stops": [[15, 0.5], [19, 1]]}, + "symbol-placement": "line", + "symbol-spacing": 75 + }, + "paint": {"icon-opacity": 0.5} + }, + { + "id": "road_oneway_opposite", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + ["==", "oneway", -1], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": -90, + "icon-rotation-alignment": "map", + "icon-size": {"stops": [[15, 0.5], [19, 1]]}, + "symbol-placement": "line", + "symbol-spacing": 75 + }, + "paint": {"icon-opacity": 0.5} + }, + { + "id": "poi-level-3", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "poi", + "minzoom": 16, + "filter": [ + "all", + ["==", "$type", "Point"], + [">=", "rank", 25], + ["any", ["!has", "level"], ["==", "level", 0]] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-level-2", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "poi", + "minzoom": 15, + "filter": [ + "all", + ["==", "$type", "Point"], + ["<=", "rank", 24], + [">=", "rank", 15], + ["any", ["!has", "level"], ["==", "level", 0]] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-level-1", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "poi", + "minzoom": 14, + "filter": [ + "all", + ["==", "$type", "Point"], + ["<=", "rank", 14], + ["has", "name"], + ["any", ["!has", "level"], ["==", "level", 0]] + ], + "layout": { + "icon-image": "{class}_11", + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-railway", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "poi", + "minzoom": 13, + "filter": [ + "all", + ["==", "$type", "Point"], + ["has", "name"], + ["==", "class", "railway"], + ["==", "subclass", "station"] + ], + "layout": { + "icon-allow-overlap": false, + "icon-ignore-placement": false, + "icon-image": "{class}_11", + "icon-optional": false, + "text-allow-overlap": false, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-ignore-placement": false, + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-optional": true, + "text-padding": 2, + "text-size": 12 + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "highway-name-path", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 15.5, + "filter": ["==", "class", "path"], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "map", + "text-size": {"base": 1, "stops": [[13, 12], [14, 13]]} + }, + "paint": { + "text-color": "hsl(30, 23%, 62%)", + "text-halo-color": "#f8f4f0", + "text-halo-width": 0.5 + } + }, + { + "id": "highway-name-minor", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 15, + "filter": [ + "all", + ["==", "$type", "LineString"], + ["in", "class", "minor", "service", "track"] + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "map", + "text-size": {"base": 1, "stops": [[13, 12], [14, 13]]} + }, + "paint": { + "text-color": "#765", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-name-major", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 12.2, + "filter": ["in", "class", "primary", "secondary", "tertiary", "trunk"], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "map", + "text-size": {"base": 1, "stops": [[13, 12], [14, 13]]} + }, + "paint": { + "text-color": "#765", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-shield", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 8, + "filter": [ + "all", + ["<=", "ref_length", 6], + ["==", "$type", "LineString"], + ["!in", "network", "us-interstate", "us-highway", "us-state"] + ], + "layout": { + "icon-image": "road_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": {"base": 1, "stops": [[10, "point"], [11, "line"]]}, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": {} + }, + { + "id": "highway-shield-us-interstate", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 7, + "filter": [ + "all", + ["<=", "ref_length", 6], + ["==", "$type", "LineString"], + ["in", "network", "us-interstate"] + ], + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [[7, "point"], [7, "line"], [8, "line"]] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": {"text-color": "rgba(0, 0, 0, 1)"} + }, + { + "id": "highway-shield-us-other", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "transportation_name", + "minzoom": 9, + "filter": [ + "all", + ["<=", "ref_length", 6], + ["==", "$type", "LineString"], + ["in", "network", "us-highway", "us-state"] + ], + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": {"base": 1, "stops": [[10, "point"], [11, "line"]]}, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": ["Noto Sans Regular"], + "text-rotation-alignment": "viewport", + "text-size": 10 + }, + "paint": {"text-color": "rgba(0, 0, 0, 1)"} + }, + { + "id": "airport-label-major", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "aerodrome_label", + "minzoom": 10, + "filter": ["all", ["has", "iata"]], + "layout": { + "icon-image": "airport_11", + "icon-size": 1, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 9, + "text-offset": [0, 0.6], + "text-optional": true, + "text-padding": 2, + "text-size": 12, + "visibility": "visible" + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "place-other", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "!in", + "class", + "city", + "town", + "village", + "state", + "country", + "continent" + ], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Bold"], + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-size": {"base": 1.2, "stops": [[12, 10], [15, 14]]}, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#633", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-village", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "openmaptiles", + "source-layer": "place", + "filter": ["==", "class", "village"], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-size": {"base": 1.2, "stops": [[10, 12], [15, 22]]}, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-town", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "openmaptiles", + "source-layer": "place", + "filter": ["==", "class", "town"], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-size": {"base": 1.2, "stops": [[10, 14], [15, 24]]}, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "openmaptiles", + "source-layer": "place", + "filter": ["all", ["!=", "capital", 2], ["==", "class", "city"]], + "layout": { + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-size": {"base": 1.2, "stops": [[7, 14], [11, 24]]}, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city-capital", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "openmaptiles", + "source-layer": "place", + "filter": ["all", ["==", "capital", 2], ["==", "class", "city"]], + "layout": { + "icon-image": "star_11", + "icon-size": 0.8, + "text-anchor": "left", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-font": ["Noto Sans Regular"], + "text-max-width": 8, + "text-offset": [0.4, 0], + "text-size": {"base": 1.2, "stops": [[7, 14], [11, 24]]}, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-state", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "openmaptiles", + "source-layer": "place", + "filter": ["in", "class", "state"], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-size": {"base": 1.2, "stops": [[12, 10], [15, 14]]}, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#633", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-country-other", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + [">=", "rank", 3], + ["!has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Italic"], + "text-max-width": 6.25, + "text-size": {"stops": [[3, 11], [7, 17]]}, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-3", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + [">=", "rank", 3], + ["has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": {"stops": [[3, 11], [7, 17]]}, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-2", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + ["==", "rank", 2], + ["has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": {"stops": [[2, 11], [5, 17]]}, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-1", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "openmaptiles", + "source-layer": "place", + "filter": [ + "all", + ["==", "class", "country"], + ["==", "rank", 1], + ["has", "iso_a2"] + ], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": {"stops": [[1, 11], [4, 17]]}, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-continent", + "type": "symbol", + "metadata": {"mapbox:group": "1444849242106.713"}, + "source": "openmaptiles", + "source-layer": "place", + "maxzoom": 1, + "filter": ["==", "class", "continent"], + "layout": { + "text-field": "{name:latin}", + "text-font": ["Noto Sans Bold"], + "text-max-width": 6.25, + "text-size": 14, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + } + ], + "id": "bright" +} \ No newline at end of file diff --git a/library/src/commonTest/composeResources/files/test_style_bright_sprite.json b/library/src/commonTest/composeResources/files/test_style_bright_sprite.json new file mode 100644 index 00000000..27fb85e7 --- /dev/null +++ b/library/src/commonTest/composeResources/files/test_style_bright_sprite.json @@ -0,0 +1,709 @@ +{ + "airfield_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 21, + "y": 0 + }, + "airport_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 0, + "y": 21 + }, + "alcohol_shop_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 17, + "y": 21 + }, + "amusement_park_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 34, + "y": 21 + }, + "aquarium_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 51, + "y": 21 + }, + "art_gallery_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 38, + "y": 0 + }, + "attraction_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 55, + "y": 0 + }, + "bakery_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 0, + "y": 38 + }, + "bank_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 17, + "y": 38 + }, + "bar_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 34, + "y": 38 + }, + "beer_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 51, + "y": 38 + }, + "bicycle_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 0, + "y": 55 + }, + "bicycle_rental_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 17, + "y": 55 + }, + "bus_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 34, + "y": 55 + }, + "cafe_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 51, + "y": 55 + }, + "campsite_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 68, + "y": 21 + }, + "car_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 85, + "y": 21 + }, + "castle_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 102, + "y": 21 + }, + "cemetery_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 119, + "y": 21 + }, + "cinema_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 136, + "y": 21 + }, + "circle_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 68, + "y": 38 + }, + "circle_stroked_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 85, + "y": 38 + }, + "clothing_store_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 102, + "y": 38 + }, + "college_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 119, + "y": 38 + }, + "dentist_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 136, + "y": 38 + }, + "doctor_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 68, + "y": 55 + }, + "dog_park_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 85, + "y": 55 + }, + "drinking_water_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 102, + "y": 55 + }, + "embassy_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 119, + "y": 55 + }, + "entrance_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 136, + "y": 55 + }, + "fast_food_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 72, + "y": 0 + }, + "ferry_terminal_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 89, + "y": 0 + }, + "fire_station_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 106, + "y": 0 + }, + "fuel_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 123, + "y": 0 + }, + "garden_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 140, + "y": 0 + }, + "golf_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 0, + "y": 72 + }, + "grocery_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 17, + "y": 72 + }, + "harbor_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 34, + "y": 72 + }, + "heliport_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 51, + "y": 72 + }, + "hospital_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 68, + "y": 72 + }, + "ice_cream_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 85, + "y": 72 + }, + "information_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 102, + "y": 72 + }, + "laundry_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 119, + "y": 72 + }, + "library_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 136, + "y": 72 + }, + "lodging_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 0, + "y": 89 + }, + "marker_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 17, + "y": 89 + }, + "monument_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 34, + "y": 89 + }, + "mountain_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 51, + "y": 89 + }, + "museum_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 68, + "y": 89 + }, + "music_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 85, + "y": 89 + }, + "oneway": { + "height": 21, + "pixelRatio": 1, + "width": 21, + "x": 0, + "y": 0 + }, + "park_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 102, + "y": 89 + }, + "pharmacy_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 119, + "y": 89 + }, + "picnic_site_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 136, + "y": 89 + }, + "pitch_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 0, + "y": 106 + }, + "place_of_worship_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 17, + "y": 106 + }, + "playground_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 34, + "y": 106 + }, + "police_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 51, + "y": 106 + }, + "post_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 68, + "y": 106 + }, + "prison_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 85, + "y": 106 + }, + "rail_light_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 102, + "y": 106 + }, + "rail_metro_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 119, + "y": 106 + }, + "railway_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 136, + "y": 106 + }, + "religious_christian_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 0, + "y": 123 + }, + "religious_jewish_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 17, + "y": 123 + }, + "religious_muslim_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 34, + "y": 123 + }, + "restaurant_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 51, + "y": 123 + }, + "road_1": { + "height": 14, + "pixelRatio": 1, + "width": 14, + "x": 280, + "y": 21 + }, + "road_2": { + "height": 14, + "pixelRatio": 1, + "width": 20, + "x": 294, + "y": 21 + }, + "road_3": { + "height": 14, + "pixelRatio": 1, + "width": 25, + "x": 153, + "y": 38 + }, + "road_4": { + "height": 14, + "pixelRatio": 1, + "width": 31, + "x": 178, + "y": 38 + }, + "road_5": { + "height": 14, + "pixelRatio": 1, + "width": 36, + "x": 209, + "y": 38 + }, + "road_6": { + "height": 14, + "pixelRatio": 1, + "width": 40, + "x": 245, + "y": 38 + }, + "rocket_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 68, + "y": 123 + }, + "school_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 85, + "y": 123 + }, + "shop_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 102, + "y": 123 + }, + "stadium_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 119, + "y": 123 + }, + "star_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 136, + "y": 123 + }, + "suitcase_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 0, + "y": 140 + }, + "swimming_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 17, + "y": 140 + }, + "theatre_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 34, + "y": 140 + }, + "toilet_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 51, + "y": 140 + }, + "town_hall_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 68, + "y": 140 + }, + "triangle_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 85, + "y": 140 + }, + "triangle_stroked_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 102, + "y": 140 + }, + "us-highway_1": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 119, + "y": 140 + }, + "us-highway_2": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 136, + "y": 140 + }, + "us-highway_3": { + "height": 17, + "pixelRatio": 1, + "width": 21, + "x": 153, + "y": 21 + }, + "us-interstate_1": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 174, + "y": 21 + }, + "us-interstate_2": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 191, + "y": 21 + }, + "us-interstate_3": { + "height": 17, + "pixelRatio": 1, + "width": 21, + "x": 208, + "y": 21 + }, + "us-state_1": { + "height": 14, + "pixelRatio": 1, + "width": 17, + "x": 314, + "y": 21 + }, + "us-state_2": { + "height": 14, + "pixelRatio": 1, + "width": 22, + "x": 285, + "y": 38 + }, + "us-state_3": { + "height": 14, + "pixelRatio": 1, + "width": 27, + "x": 307, + "y": 38 + }, + "us-state_4": { + "height": 14, + "pixelRatio": 1, + "width": 32, + "x": 153, + "y": 55 + }, + "us-state_5": { + "height": 14, + "pixelRatio": 1, + "width": 37, + "x": 185, + "y": 55 + }, + "us-state_6": { + "height": 14, + "pixelRatio": 1, + "width": 42, + "x": 222, + "y": 55 + }, + "veterinary_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 229, + "y": 21 + }, + "volcano_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 246, + "y": 21 + }, + "wave": { + "height": 8, + "pixelRatio": 1, + "width": 16, + "x": 264, + "y": 55 + }, + "zoo_11": { + "height": 17, + "pixelRatio": 1, + "width": 17, + "x": 263, + "y": 21 + } +} \ No newline at end of file diff --git a/library/src/commonTest/composeResources/files/test_style_bright_sprite.png b/library/src/commonTest/composeResources/files/test_style_bright_sprite.png new file mode 100644 index 00000000..79cafb16 Binary files /dev/null and b/library/src/commonTest/composeResources/files/test_style_bright_sprite.png differ diff --git a/library/src/commonTest/composeResources/files/test_style_simple.json b/library/src/commonTest/composeResources/files/test_style_simple.json new file mode 100644 index 00000000..4e3d981d --- /dev/null +++ b/library/src/commonTest/composeResources/files/test_style_simple.json @@ -0,0 +1,546 @@ +{ + "id": "43f36e14-e3f5-43c1-84c0-50a9c80dc5c7", + "name": "MapLibre", + "zoom": 0, + "pitch": 0, + "center": [ + 17.65431710431244, + 32.954120326746775 + ], + "glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "#D8F2FF" + }, + "filter": [ + "all" + ], + "layout": { + "visibility": "visible" + }, + "maxzoom": 24 + }, + { + "id": "coastline", + "type": "line", + "paint": { + "line-blur": 0.5, + "line-color": "#198EC8", + "line-width": { + "stops": [ + [ + 0, + 2 + ], + [ + 6, + 6 + ], + [ + 14, + 9 + ], + [ + 22, + 18 + ] + ] + } + }, + "filter": [ + "all" + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "source": "maplibre", + "maxzoom": 24, + "minzoom": 0, + "source-layer": "countries" + }, + { + "id": "countries-fill", + "type": "fill", + "paint": { + "fill-color": [ + "match", + [ + "get", + "ADM0_A3" + ], + [ + "ARM", + "ATG", + "AUS", + "BTN", + "CAN", + "COG", + "CZE", + "GHA", + "GIN", + "HTI", + "ISL", + "JOR", + "KHM", + "KOR", + "LVA", + "MLT", + "MNE", + "MOZ", + "PER", + "SAH", + "SGP", + "SLV", + "SOM", + "TJK", + "TUV", + "UKR", + "WSM" + ], + "#D6C7FF", + [ + "AZE", + "BGD", + "CHL", + "CMR", + "CSI", + "DEU", + "DJI", + "GUY", + "HUN", + "IOA", + "JAM", + "LBN", + "LBY", + "LSO", + "MDG", + "MKD", + "MNG", + "MRT", + "NIU", + "NZL", + "PCN", + "PYF", + "SAU", + "SHN", + "STP", + "TTO", + "UGA", + "UZB", + "ZMB" + ], + "#EBCA8A", + [ + "AGO", + "ASM", + "ATF", + "BDI", + "BFA", + "BGR", + "BLZ", + "BRA", + "CHN", + "CRI", + "ESP", + "HKG", + "HRV", + "IDN", + "IRN", + "ISR", + "KNA", + "LBR", + "LCA", + "MAC", + "MUS", + "NOR", + "PLW", + "POL", + "PRI", + "SDN", + "TUN", + "UMI", + "USA", + "USG", + "VIR", + "VUT" + ], + "#C1E599", + [ + "ARE", + "ARG", + "BHS", + "CIV", + "CLP", + "DMA", + "ETH", + "GAB", + "GRD", + "HMD", + "IND", + "IOT", + "IRL", + "IRQ", + "ITA", + "KOS", + "LUX", + "MEX", + "NAM", + "NER", + "PHL", + "PRT", + "RUS", + "SEN", + "SUR", + "TZA", + "VAT" + ], + "#E7E58F", + [ + "AUT", + "BEL", + "BHR", + "BMU", + "BRB", + "CYN", + "DZA", + "EST", + "FLK", + "GMB", + "GUM", + "HND", + "JEY", + "KGZ", + "LIE", + "MAF", + "MDA", + "NGA", + "NRU", + "SLB", + "SOL", + "SRB", + "SWZ", + "THA", + "TUR", + "VEN", + "VGB" + ], + "#98DDA1", + [ + "AIA", + "BIH", + "BLM", + "BRN", + "CAF", + "CHE", + "COM", + "CPV", + "CUB", + "ECU", + "ESB", + "FSM", + "GAZ", + "GBR", + "GEO", + "KEN", + "LTU", + "MAR", + "MCO", + "MDV", + "NFK", + "NPL", + "PNG", + "PRY", + "QAT", + "SLE", + "SPM", + "SYC", + "TCA", + "TKM", + "TLS", + "VNM", + "WEB", + "WSB", + "YEM", + "ZWE" + ], + "#83D5F4", + [ + "ABW", + "ALB", + "AND", + "ATC", + "BOL", + "COD", + "CUW", + "CYM", + "CYP", + "EGY", + "FJI", + "GGY", + "IMN", + "KAB", + "KAZ", + "KWT", + "LAO", + "MLI", + "MNP", + "MSR", + "MYS", + "NIC", + "NLD", + "PAK", + "PAN", + "PRK", + "ROU", + "SGS", + "SVN", + "SWE", + "TGO", + "TWN", + "VCT", + "ZAF" + ], + "#B1BBF9", + [ + "ATA", + "GRL" + ], + "#FFFFFF", + "#EAB38F" + ] + }, + "filter": [ + "all" + ], + "layout": { + "visibility": "visible" + }, + "source": "maplibre", + "maxzoom": 24, + "source-layer": "countries" + }, + { + "id": "countries-boundary", + "type": "line", + "paint": { + "line-color": "rgba(255, 255, 255, 1)", + "line-width": { + "stops": [ + [ + 1, + 1 + ], + [ + 6, + 2 + ], + [ + 14, + 6 + ], + [ + 22, + 12 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 3, + 0.5 + ], + [ + 6, + 1 + ] + ] + } + }, + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "source": "maplibre", + "maxzoom": 24, + "source-layer": "countries" + }, + { + "id": "geolines", + "type": "line", + "paint": { + "line-color": "#1077B0", + "line-opacity": 1, + "line-dasharray": [ + 3, + 3 + ] + }, + "filter": [ + "all", + [ + "!=", + "name", + "International Date Line" + ] + ], + "layout": { + "visibility": "visible" + }, + "source": "maplibre", + "maxzoom": 24, + "source-layer": "geolines" + }, + { + "id": "geolines-label", + "type": "symbol", + "paint": { + "text-color": "#1077B0", + "text-halo-blur": 1, + "text-halo-color": "rgba(255, 255, 255, 1)", + "text-halo-width": 1 + }, + "filter": [ + "all", + [ + "!=", + "name", + "International Date Line" + ] + ], + "layout": { + "text-font": [ + "Open Sans Semibold" + ], + "text-size": { + "stops": [ + [ + 2, + 12 + ], + [ + 6, + 16 + ] + ] + }, + "text-field": "{name}", + "visibility": "visible", + "symbol-placement": "line" + }, + "source": "maplibre", + "maxzoom": 24, + "minzoom": 1, + "source-layer": "geolines" + }, + { + "id": "countries-label", + "type": "symbol", + "paint": { + "text-color": "rgba(8, 37, 77, 1)", + "text-halo-blur": { + "stops": [ + [ + 2, + 0.2 + ], + [ + 6, + 0 + ] + ] + }, + "text-halo-color": "rgba(255, 255, 255, 1)", + "text-halo-width": { + "stops": [ + [ + 2, + 1 + ], + [ + 6, + 1.6 + ] + ] + } + }, + "filter": [ + "all" + ], + "layout": { + "text-font": [ + "Open Sans Semibold" + ], + "text-size": { + "stops": [ + [ + 2, + 10 + ], + [ + 4, + 12 + ], + [ + 6, + 16 + ] + ] + }, + "text-field": { + "stops": [ + [ + 2, + "{ABBREV}" + ], + [ + 4, + "{NAME}" + ] + ] + }, + "visibility": "visible", + "text-max-width": 10, + "text-transform": { + "stops": [ + [ + 0, + "uppercase" + ], + [ + 2, + "none" + ] + ] + } + }, + "source": "maplibre", + "maxzoom": 24, + "minzoom": 2, + "source-layer": "centroids" + } + ], + "bearing": 0, + "sources": { + "maplibre": { + "tiles": [ + "https://demotiles.maplibre.org/tiles/{z}/{x}/{y}.pbf" + ], + "minzoom": 0, + "maxzoom": 6, + "attribution": "© MapLibre demo", + "type": "vector" + } + }, + "version": 8, + "metadata": { + "maptiler:copyright": "This style was generated on MapTiler Cloud. Usage is governed by the license terms in https://github.com/maplibre/demotiles/blob/gh-pages/LICENSE", + "openmaptiles:version": "3.x" + } +} \ No newline at end of file diff --git a/library/src/commonTest/composeResources/files/test_style_street_v2.json b/library/src/commonTest/composeResources/files/test_style_street_v2.json new file mode 100644 index 00000000..504156ed --- /dev/null +++ b/library/src/commonTest/composeResources/files/test_style_street_v2.json @@ -0,0 +1,8252 @@ +{ + "version": 8, + "id": "streets-v2", + "name": "Streets", + "sources": { + "maptiler_attribution": { + "attribution": "© MapTiler © OpenStreetMap contributors", + "type": "vector" + }, + "maptiler_planet": { + "tiles": [ + "https://api.maptiler.com/tiles/v3/{z}/{x}/{y}.pbf?key=XXX" + ], + "type": "vector", + "minzoom": 0, + "maxzoom": 15, + "attribution": "© MapTiler © OpenStreetMap contributors" + } + }, + "layers": [ + { + "id": "Background", + "type": "background", + "layout": { + "visibility": "visible" + }, + "paint": { + "background-color": { + "stops": [ + [ + 6, + "hsl(47,79%,94%)" + ], + [ + 14, + "hsl(42,49%,93%)" + ] + ] + } + } + }, + { + "id": "Meadow", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "globallandcover", + "maxzoom": 8, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(75,51%,85%)", + "fill-opacity": { + "stops": [ + [ + 0, + 1 + ], + [ + 8, + 0.1 + ] + ] + } + }, + "filter": [ + "==", + "class", + "grass" + ] + }, + { + "id": "Scrub", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "globallandcover", + "maxzoom": 8, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(97,51%,80%)", + "fill-opacity": { + "stops": [ + [ + 0, + 1 + ], + [ + 8, + 0.1 + ] + ] + } + }, + "filter": [ + "==", + "class", + "scrub" + ] + }, + { + "id": "Crop", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "globallandcover", + "maxzoom": 8, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(50,67%,86%)", + "fill-opacity": { + "stops": [ + [ + 0, + 1 + ], + [ + 8, + 0.1 + ] + ] + } + }, + "filter": [ + "==", + "class", + "crop" + ] + }, + { + "id": "Glacier", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landcover", + "maxzoom": 24, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(0,0%,100%)", + "fill-opacity": { + "stops": [ + [ + 0, + 1 + ], + [ + 10, + 0.7 + ] + ] + } + }, + "filter": [ + "==", + "class", + "ice" + ] + }, + { + "id": "Forest", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "globallandcover", + "maxzoom": 8, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(119,38%,76%)", + "fill-opacity": { + "stops": [ + [ + 1, + 0.8 + ], + [ + 8, + 0 + ] + ] + } + }, + "filter": [ + "in", + "class", + "forest", + "tree" + ] + }, + { + "id": "Sand", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landcover", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": false, + "fill-color": "hsl(52,93%,89%)", + "fill-opacity": 0.85 + }, + "metadata": {}, + "filter": [ + "==", + "class", + "sand" + ] + }, + { + "id": "Wood", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landcover", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(87,46%,85%)", + "fill-opacity": 1 + }, + "metadata": {}, + "filter": [ + "==", + "class", + "wood" + ] + }, + { + "id": "Residential", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landuse", + "maxzoom": 24, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [ + 4, + "hsl(44,34%,87%)" + ], + [ + 16, + "hsl(54, 45%, 91%)" + ] + ] + } + }, + "metadata": {}, + "filter": [ + "in", + "class", + "residential", + "suburbs", + "neighbourhood" + ] + }, + { + "id": "Industrial", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landuse", + "maxzoom": 24, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 9, + [ + "match", + [ + "get", + "class" + ], + [ + "industrial" + ], + "hsl(40,67%,90%)", + "quarry", + "hsla(32, 47%, 87%, 0.2)", + "hsl(60, 31%, 87%)" + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "industrial" + ], + "hsl(49,54%,90%)", + "quarry", + "hsla(32, 47%, 87%, 0.5)", + "hsl(60, 31%, 87%)" + ] + ], + "fill-opacity": [ + "step", + [ + "zoom" + ], + 1, + 9, + [ + "match", + [ + "get", + "class" + ], + "quarry", + 0, + 1 + ], + 10, + 1 + ] + }, + "metadata": {}, + "filter": [ + "in", + "class", + "industrial", + "quarry" + ] + }, + { + "id": "Grass", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landcover", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": false, + "fill-color": "hsl(103, 40%, 85%)", + "fill-opacity": 0.5 + }, + "metadata": {}, + "filter": [ + "==", + "class", + "grass" + ] + }, + { + "id": "Airport zone", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "aeroway", + "minzoom": 11, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(0,0%,93%)", + "fill-opacity": 1 + }, + "metadata": {}, + "filter": [ + "==", + "$type", + "Polygon" + ] + }, + { + "id": "Pedestrian", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(43,100%,99%)", + "fill-opacity": 0.7 + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "!has", + "brunnel" + ], + [ + "!in", + "class", + "bridge", + "pier" + ], + [ + "in", + "subclass", + "pedestrian", + "platform" + ] + ] + }, + { + "id": "Cemetery", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landuse", + "minzoom": 9, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(0,0%,88%)", + "fill-opacity": { + "stops": [ + [ + 9, + 0.25 + ], + [ + 16, + 1 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "==", + "class", + "cemetery" + ] + }, + { + "id": "Hospital", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landuse", + "minzoom": 9, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(12,63%,94%)", + "fill-opacity": { + "stops": [ + [ + 9, + 0.25 + ], + [ + 16, + 1 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "==", + "class", + "hospital" + ] + }, + { + "id": "Stadium", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landuse", + "minzoom": 9, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(94, 100%, 88%)", + "fill-opacity": { + "stops": [ + [ + 9, + 0.25 + ], + [ + 16, + 1 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "in", + "class", + "pitch", + "stadium", + "playground" + ] + }, + { + "id": "School", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "landuse", + "minzoom": 9, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(194,52%,94%)", + "fill-opacity": { + "stops": [ + [ + 9, + 0.25 + ], + [ + 16, + 1 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "in", + "class", + "college", + "school", + "university" + ] + }, + { + "id": "River tunnel", + "type": "line", + "source": "maptiler_planet", + "source-layer": "waterway", + "minzoom": 14, + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(210,73%,78%)", + "line-dasharray": [ + 2, + 4 + ], + "line-opacity": 0.5, + "line-width": { + "base": 1.3, + "stops": [ + [ + 12, + 0.5 + ], + [ + 20, + 6 + ] + ] + } + }, + "filter": [ + "==", + "brunnel", + "tunnel" + ] + }, + { + "id": "River", + "type": "line", + "source": "maptiler_planet", + "source-layer": "waterway", + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(210,73%,78%)", + "line-width": { + "stops": [ + [ + 12, + 0.5 + ], + [ + 20, + 6 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "!=", + "brunnel", + "tunnel" + ] + }, + { + "id": "Water intermittent", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "water", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(205,91%,83%)", + "fill-opacity": 0.85 + }, + "metadata": {}, + "filter": [ + "==", + "intermittent", + 1 + ] + }, + { + "id": "Water", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "water", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(204,92%,75%)", + "fill-opacity": [ + "match", + [ + "get", + "intermittent" + ], + 1, + 0.85, + 1 + ] + }, + "metadata": {}, + "filter": [ + "!=", + "intermittent", + 1 + ] + }, + { + "id": "Aeroway", + "type": "line", + "source": "maptiler_planet", + "source-layer": "aeroway", + "minzoom": 11, + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,100%)", + "line-width": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 11, + [ + "match", + [ + "get", + "class" + ], + [ + "runway" + ], + 3, + 0.5 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "runway" + ], + 16, + 6 + ] + ] + }, + "metadata": {} + }, + { + "id": "Heliport", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "aeroway", + "minzoom": 11, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(0,0%,100%)", + "fill-opacity": 1 + }, + "metadata": {}, + "filter": [ + "in", + "class", + "helipad", + "heliport" + ] + }, + { + "id": "Ferry line", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 6, + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": { + "stops": [ + [ + 10, + "hsl(205,61%,63%)" + ], + [ + 16, + "hsl(205,67%,47%)" + ] + ] + }, + "line-dasharray": [ + 2, + 2 + ], + "line-opacity": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 6, + 0.5, + 7, + 0.8, + 8, + 1 + ], + "line-width": { + "stops": [ + [ + 10, + 0.5 + ], + [ + 14, + 1.1 + ] + ] + } + }, + "filter": [ + "==", + "class", + "ferry" + ] + }, + { + "id": "Tunnel outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": [ + "match", + [ + "get", + "class" + ], + "motorway", + "hsl(28,72%,69%)", + [ + "trunk", + "primary" + ], + "hsl(28,72%,69%)", + "hsl(36,5%,80%)" + ], + "line-dasharray": [ + 0.5, + 0.25 + ], + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 6, + 0, + 7, + 0.5, + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 0, + 2.5 + ], + [ + "trunk", + "primary" + ], + 2, + 0 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 2, + 6 + ], + [ + "trunk", + "primary" + ], + 3, + [ + "secondary", + "tertiary" + ], + 2, + [ + "minor", + "service", + "track" + ], + 1, + 0.5 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 5, + 8 + ], + [ + "trunk" + ], + 4, + [ + "primary" + ], + 6, + [ + "secondary" + ], + 6, + [ + "tertiary" + ], + 4, + [ + "minor", + "service", + "track" + ], + 3, + 3 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway", + "trunk", + "primary" + ], + 10, + [ + "secondary" + ], + 8, + [ + "tertiary" + ], + 8, + [ + "minor", + "service", + "track" + ], + 4, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway", + "trunk", + "primary" + ], + 26, + [ + "secondary" + ], + 26, + [ + "tertiary" + ], + 26, + [ + "minor", + "service", + "track" + ], + 18, + 18 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "!in", + "class", + "bridge", + "ferry", + "rail", + "transit", + "pier", + "path", + "aerialway", + "motorway_construction", + "trunk_construction", + "primary_construction", + "secondary_construction", + "tertiary_construction", + "minor_construction", + "service_construction", + "track_construction" + ] + ] + }, + { + "id": "Tunnel", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": [ + "match", + [ + "get", + "class" + ], + "motorway", + "hsl(35,100%,76%)", + [ + "trunk", + "primary" + ], + "hsl(48,100%,88%)", + "hsl(0,0%,96%)" + ], + "line-opacity": 1, + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 5, + 0, + 6, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "brunnel" + ], + [ + "bridge" + ], + 0, + 1 + ], + [ + "trunk", + "primary" + ], + 0, + 0 + ], + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 0, + 2.5 + ], + [ + "trunk", + "primary" + ], + 1.5, + 1 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 1, + 4 + ], + [ + "trunk" + ], + 2.5, + [ + "primary" + ], + 2.5, + [ + "secondary", + "tertiary" + ], + 1.5, + [ + "minor", + "service", + "track" + ], + 1, + 1 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 5, + 6 + ], + [ + "trunk" + ], + 3, + [ + "primary" + ], + 5, + [ + "secondary" + ], + 4, + [ + "tertiary" + ], + 3, + [ + "minor", + "service", + "track" + ], + 2, + 2 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway", + "trunk", + "primary" + ], + 8, + [ + "secondary" + ], + 7, + [ + "tertiary" + ], + 6, + [ + "minor", + "service", + "track" + ], + 4, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway", + "trunk", + "primary" + ], + 24, + [ + "secondary" + ], + 24, + [ + "tertiary" + ], + 24, + [ + "minor", + "service", + "track" + ], + 16, + 16 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "!in", + "class", + "ferry", + "rail", + "transit", + "pier", + "bridge", + "path", + "aerialway", + "motorway_construction", + "trunk_construction", + "primary_construction", + "secondary_construction", + "tertiary_construction", + "minor_construction", + "service_construction", + "track_construction" + ] + ] + }, + { + "id": "Railway tunnel", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,73%)", + "line-opacity": 0.5, + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ] + }, + { + "id": "Railway tunnel hatching", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,73%)", + "line-dasharray": [ + 0.2, + 8 + ], + "line-opacity": 0.5, + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 3 + ], + [ + 20, + 8 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ] + }, + { + "id": "Footway tunnel outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 12, + "layout": { + "line-cap": "round", + "line-join": "miter", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,100%)", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 14, + 0 + ], + [ + 16, + 0 + ], + [ + 18, + 4 + ], + [ + 22, + 8 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "path", + "pedestrian" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ] + }, + { + "id": "Footway tunnel", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 12, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,63%)", + "line-dasharray": { + "stops": [ + [ + 14, + [ + 1, + 0.5 + ] + ], + [ + 18, + [ + 1, + 0.25 + ] + ] + ] + }, + "line-opacity": 0.4, + "line-width": { + "base": 1.2, + "stops": [ + [ + 14, + 0.5 + ], + [ + 16, + 1 + ], + [ + 18, + 2 + ], + [ + 22, + 5 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "path", + "pedestrian" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ] + }, + { + "id": "Pier", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(42,49%,93%)" + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "class", + "pier" + ] + ] + }, + { + "id": "Pier road", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(42,49%,93%)", + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1 + ], + [ + 17, + 4 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "pier" + ] + ] + }, + { + "id": "Bridge outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 17, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(43, 50%, 93%)", + "line-opacity": 0.5, + "line-width": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 13, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 5, + 8 + ], + 15, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 15, + 22 + ], + 17, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 20, + 24 + ] + ] + }, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "motorway", + "primary", + "trunk" + ] + ] + }, + { + "id": "Bridge", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-antialias": true, + "fill-color": "hsl(42,49%,93%)", + "fill-opacity": 0.6 + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "brunnel", + "bridge" + ] + ] + }, + { + "id": "Minor road outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(36,5%,80%)", + "line-opacity": 1, + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 6, + 0, + 7, + 0.5, + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary", + "tertiary" + ], + 2, + [ + "minor", + "service", + "track" + ], + 1, + 0.5 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary" + ], + 6, + [ + "tertiary" + ], + 4, + [ + "minor", + "service", + "track" + ], + 3, + 3 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary" + ], + 8, + [ + "tertiary" + ], + 8, + [ + "minor", + "service", + "track" + ], + 4, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary" + ], + 26, + [ + "tertiary" + ], + 26, + [ + "minor", + "service", + "track" + ], + 18, + 18 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "!in", + "class", + "aerialway", + "bridge", + "ferry", + "minor_construction", + "motorway", + "motorway_construction", + "path", + "path_construction", + "pier", + "primary", + "primary_construction", + "rail", + "secondary_construction", + "service_construction", + "tertiary_construction", + "track_construction", + "transit", + "trunk_construction" + ] + ] + }, + { + "id": "Major road outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(28,72%,69%)", + "line-opacity": 1, + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 6, + 0, + 7, + 0.5, + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 2.4, + 0 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 3, + 0.5 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk" + ], + 4, + [ + "primary" + ], + 6, + 3 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 10, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 26, + 18 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "primary", + "trunk" + ] + ] + }, + { + "id": "Highway outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(28,72%,69%)", + "line-opacity": 1, + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 6, + 0, + 7, + 0.5, + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 0, + 2.5 + ], + 0 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 2, + 6 + ], + 0.5 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 5, + 8 + ], + 3 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + 10, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + 26, + 18 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + }, + { + "id": "Road under construction", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "square", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": [ + "match", + [ + "get", + "class" + ], + "motorway_construction", + "hsl(35,100%,76%)", + [ + "trunk_construction", + "primary_construction" + ], + "hsl(48,100%,83%)", + "hsl(0,0%,100%)" + ], + "line-dasharray": [ + 2, + 2 + ], + "line-opacity": [ + "case", + [ + "==", + [ + "get", + "brunnel" + ], + "tunnel" + ], + 0.7, + 1 + ], + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 5, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway_construction" + ], + [ + "match", + [ + "get", + "brunnel" + ], + [ + "bridge" + ], + 0, + 0.5 + ], + [ + "trunk_construction", + "primary_construction" + ], + 0, + 0 + ], + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway_construction" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 0, + 2.5 + ], + [ + "trunk_construction", + "primary_construction" + ], + 1.5, + 1 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway_construction" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 1, + 4 + ], + [ + "trunk_construction" + ], + 2.5, + [ + "primary_construction" + ], + 2.5, + [ + "secondary_construction", + "tertiary_construction" + ], + 1.5, + [ + "minor_construction", + "service_construction", + "track_construction" + ], + 1, + 1 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway_construction" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 5, + 6 + ], + [ + "trunk_construction" + ], + 3, + [ + "primary_construction" + ], + 5, + [ + "secondary_construction" + ], + 4, + [ + "tertiary_construction" + ], + 3, + [ + "minor_construction", + "service_construction", + "track_construction" + ], + 2, + 2 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway_construction", + "trunk_construction", + "primary_construction" + ], + 8, + [ + "secondary_construction" + ], + 7, + [ + "tertiary_construction" + ], + 6, + [ + "minor_construction", + "service_construction", + "track_construction" + ], + 4, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway_construction", + "trunk_construction", + "primary_construction" + ], + 24, + [ + "secondary_construction" + ], + 24, + [ + "tertiary_construction" + ], + 24, + [ + "minor_construction", + "service_construction", + "track_construction" + ], + 16, + 16 + ] + ] + }, + "metadata": {}, + "filter": [ + "in", + "class", + "motorway_construction", + "trunk_construction", + "primary_construction", + "secondary_construction", + "tertiary_construction", + "minor_construction", + "service_construction", + "track_construction" + ] + }, + { + "id": "Minor road", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,100%)", + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 5, + 0.5, + 10, + 1, + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary", + "tertiary" + ], + 1.5, + [ + "minor", + "service", + "track" + ], + 1, + 1 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary" + ], + 4, + [ + "tertiary" + ], + 3, + [ + "minor", + "service", + "track" + ], + 2, + 2 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary" + ], + 7, + [ + "tertiary" + ], + 6, + [ + "minor", + "service", + "track" + ], + 4, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "secondary" + ], + 24, + [ + "tertiary" + ], + 24, + [ + "minor", + "service", + "track" + ], + 16, + 16 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "!in", + "class", + "aerialway", + "bridge", + "ferry", + "minor_construction", + "motorway", + "motorway_construction", + "path", + "path_construction", + "pier", + "primary", + "primary_construction", + "rail", + "secondary_construction", + "service_construction", + "tertiary_construction", + "track_construction", + "transit", + "trunk_construction" + ] + ] + }, + { + "id": "Major road", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(48,100%,83%)", + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 1.5, + 1 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 2.5, + 1 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk" + ], + 3, + [ + "primary" + ], + 5, + 2 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 8, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "trunk", + "primary" + ], + 24, + 16 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "in", + "class", + "primary", + "trunk" + ] + ] + }, + { + "id": "Highway", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 4, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(35,100%,76%)", + "line-width": [ + "interpolate", + [ + "linear", + 2 + ], + [ + "zoom" + ], + 5, + 0.5, + 6, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "brunnel" + ], + [ + "bridge" + ], + 0, + 1 + ], + 0 + ], + 10, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 0, + 2.5 + ], + 1 + ], + 12, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 1, + 4 + ], + 1 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + [ + "match", + [ + "get", + "ramp" + ], + 1, + 5, + 6 + ], + 2 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + 8, + 4 + ], + 20, + [ + "match", + [ + "get", + "class" + ], + [ + "motorway" + ], + 24, + 16 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "!=", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + }, + { + "id": "Path outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 12, + "layout": { + "line-cap": "round", + "line-join": "miter", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,100%)", + "line-width": { + "base": 1.2, + "stops": [ + [ + 14, + 0 + ], + [ + 16, + 0 + ], + [ + 18, + 4 + ], + [ + 22, + 8 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "path", + "pedestrian" + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ] + }, + { + "id": "Path minor", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 12, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 79%)", + "line-dasharray": { + "stops": [ + [ + 14, + [ + 1, + 0.5 + ] + ], + [ + 18, + [ + 1, + 0.25 + ] + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 14, + 0.5 + ], + [ + 16, + 1 + ], + [ + 18, + 2 + ], + [ + 22, + 5 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "path_pedestrian" + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ] + }, + { + "id": "Path", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 12, + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 79%)", + "line-dasharray": { + "stops": [ + [ + 14, + [ + 1, + 0.5 + ] + ], + [ + 18, + [ + 1, + 0.25 + ] + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 14, + 0.5 + ], + [ + 16, + 1 + ], + [ + 18, + 2 + ], + [ + 22, + 5 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "path", + "pedestrian" + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ] + }, + { + "id": "Major rail", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": { + "stops": [ + [ + 8, + "hsl(0,0%,72%)" + ], + [ + 16, + "hsl(0,0%,70%)" + ] + ] + }, + "line-opacity": [ + "match", + [ + "get", + "service" + ], + "yard", + 0.5, + 1 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "!in", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ] + }, + { + "id": "Major rail hatching", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,72%)", + "line-dasharray": [ + 0.2, + 9 + ], + "line-opacity": [ + "match", + [ + "get", + "service" + ], + "yard", + 0.5, + 1 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 3 + ], + [ + 20, + 8 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "all", + [ + "!in", + "brunnel", + "tunnel" + ], + [ + "==", + "class", + "rail" + ] + ] + }, + { + "id": "Minor rail", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,73%)", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "in", + "subclass", + "light_rail", + "tram" + ] + }, + { + "id": "Minor rail hatching", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,73%)", + "line-dasharray": [ + 0.2, + 4 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 2 + ], + [ + 20, + 6 + ] + ] + } + }, + "metadata": {}, + "filter": [ + "in", + "subclass", + "tram", + "light_rail" + ] + }, + { + "id": "Building", + "type": "fill", + "source": "maptiler_planet", + "source-layer": "building", + "minzoom": 13, + "maxzoom": 15, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "hsl(30,6%,73%)", + "fill-opacity": 0.3, + "fill-outline-color": { + "base": 1, + "stops": [ + [ + 13, + "hsla(35, 6%, 79%, 0.3)" + ], + [ + 14, + "hsl(35, 6%, 79%)" + ] + ] + } + }, + "metadata": {} + }, + { + "id": "Building 3D", + "type": "fill-extrusion", + "source": "maptiler_planet", + "source-layer": "building", + "minzoom": 15, + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-extrusion-base": { + "property": "render_min_height", + "type": "identity" + }, + "fill-extrusion-color": "hsl(44,14%,79%)", + "fill-extrusion-height": { + "property": "render_height", + "type": "identity" + }, + "fill-extrusion-opacity": 0.4 + }, + "metadata": {}, + "filter": [ + "!has", + "hide_3d" + ] + }, + { + "id": "Aqueduct outline", + "type": "line", + "source": "maptiler_planet", + "source-layer": "waterway", + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,51%)", + "line-width": { + "base": 1.3, + "stops": [ + [ + 14, + 1 + ], + [ + 20, + 6 + ] + ] + } + }, + "filter": [ + "==", + "brunnel", + "bridge" + ] + }, + { + "id": "Aqueduct", + "type": "line", + "source": "maptiler_planet", + "source-layer": "waterway", + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(204,92%,75%)", + "line-width": { + "base": 1.3, + "stops": [ + [ + 12, + 0.5 + ], + [ + 20, + 5 + ] + ] + } + }, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "brunnel", + "bridge" + ] + ] + }, + { + "id": "Cablecar", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 13, + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-blur": 1, + "line-color": "hsl(0,0%,100%)", + "line-width": { + "base": 1, + "stops": [ + [ + 13, + 2 + ], + [ + 19, + 4 + ] + ] + } + }, + "filter": [ + "==", + "class", + "aerialway" + ] + }, + { + "id": "Cablecar dash", + "type": "line", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 13, + "layout": { + "line-cap": "round", + "line-join": "bevel", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0,0%,64%)", + "line-dasharray": [ + 2, + 2 + ], + "line-width": { + "base": 1, + "stops": [ + [ + 13, + 1 + ], + [ + 19, + 2 + ] + ] + } + }, + "filter": [ + "==", + "class", + "aerialway" + ] + }, + { + "id": "Other border", + "type": "line", + "source": "maptiler_planet", + "source-layer": "boundary", + "minzoom": 3, + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 70%)", + "line-dasharray": [ + 2, + 1 + ], + "line-width": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 3, + 0.75, + 4, + 0.8, + 11, + [ + "case", + [ + "<=", + [ + "get", + "admin_level" + ], + 6 + ], + 1.75, + 1.5 + ], + 18, + [ + "case", + [ + "<=", + [ + "get", + "admin_level" + ], + 6 + ], + 3, + 2 + ] + ] + }, + "filter": [ + "all", + [ + "in", + "admin_level", + 3, + 4, + 5, + 6, + 7, + 8 + ], + [ + "==", + "maritime", + 0 + ] + ] + }, + { + "id": "Disputed border", + "type": "line", + "source": "maptiler_planet", + "source-layer": "boundary", + "minzoom": 0, + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 63%)", + "line-dasharray": [ + 2, + 2 + ], + "line-width": { + "stops": [ + [ + 1, + 0.5 + ], + [ + 5, + 1.5 + ], + [ + 10, + 2 + ], + [ + 24, + 12 + ] + ] + } + }, + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "==", + "disputed", + 1 + ], + [ + "==", + "maritime", + 0 + ] + ] + }, + { + "id": "Country border", + "type": "line", + "source": "maptiler_planet", + "source-layer": "boundary", + "minzoom": 0, + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(0, 0%, 54%)", + "line-width": { + "stops": [ + [ + 1, + 0.5 + ], + [ + 5, + 1.5 + ], + [ + 10, + 2 + ], + [ + 24, + 12 + ] + ] + } + }, + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "==", + "disputed", + 0 + ], + [ + "==", + "maritime", + 0 + ] + ] + }, + { + "id": "River labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "waterway", + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "symbol-spacing": 400, + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Italic", + "Noto Sans Italic" + ], + "text-letter-spacing": 0.2, + "text-max-width": 5, + "text-rotation-alignment": "map", + "text-size": { + "stops": [ + [ + 12, + 8 + ], + [ + 16, + 14 + ], + [ + 22, + 20 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(205,84%,39%)", + "text-halo-blur": 1, + "text-halo-color": "hsl(202, 76%, 82%)", + "text-halo-width": { + "stops": [ + [ + 10, + 1 + ], + [ + 18, + 2 + ] + ] + } + }, + "filter": [ + "==", + "$type", + "LineString" + ] + }, + { + "id": "Ocean labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "water_name", + "minzoom": 0, + "layout": { + "symbol-placement": "point", + "text-field": "{name:en}", + "text-font": [ + "Roboto Italic", + "Noto Sans Italic" + ], + "text-max-width": 5, + "text-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 1, + [ + "match", + [ + "get", + "class" + ], + [ + "ocean" + ], + 14, + 10 + ], + 3, + [ + "match", + [ + "get", + "class" + ], + [ + "ocean" + ], + 18, + 14 + ], + 9, + [ + "match", + [ + "get", + "class" + ], + [ + "ocean" + ], + 22, + 18 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "lake" + ], + 14, + [ + "sea" + ], + 20, + 26 + ] + ], + "visibility": "visible" + }, + "paint": { + "text-color": { + "stops": [ + [ + 1, + "hsl(203,54%,54%)" + ], + [ + 4, + "hsl(203,72%,39%)" + ] + ] + }, + "text-halo-blur": 1, + "text-halo-color": { + "stops": [ + [ + 1, + "hsla(196, 72%, 80%, 0.05)" + ], + [ + 3, + "hsla(200, 100%, 88%, 0.75)" + ] + ] + }, + "text-halo-width": 1, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 1, + [ + "match", + [ + "get", + "class" + ], + [ + "ocean" + ], + 1, + 0 + ], + 3, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "has", + "name" + ], + [ + "!=", + "class", + "lake" + ] + ] + }, + { + "id": "Lake labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "water_name", + "minzoom": 0, + "layout": { + "symbol-placement": "line", + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Italic", + "Noto Sans Italic" + ], + "text-letter-spacing": 0.1, + "text-max-width": 5, + "text-size": { + "stops": [ + [ + 10, + 13 + ], + [ + 14, + 16 + ], + [ + 22, + 20 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(205,84%,39%)", + "text-halo-color": "hsla(0, 100%, 100%, 0.45)", + "text-halo-width": 1.5 + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "lake" + ] + ] + }, + { + "id": "Housenumber", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "housenumber", + "minzoom": 18, + "layout": { + "text-field": "{housenumber}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-size": 10, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(26,10%,44%)", + "text-halo-blur": 1, + "text-halo-color": "hsl(21,64%,96%)", + "text-halo-width": 1 + } + }, + { + "id": "Gondola", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 13, + "layout": { + "symbol-placement": "line", + "text-anchor": "center", + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Italic", + "Noto Sans Italic" + ], + "text-offset": [ + 0.8, + 0.8 + ], + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 11 + ], + [ + 15, + 12 + ], + [ + 18, + 13 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(0,0%,40%)", + "text-halo-blur": 1, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1 + }, + "metadata": {}, + "filter": [ + "in", + "subclass", + "gondola", + "cable_car" + ] + }, + { + "id": "Ferry", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 12, + "layout": { + "symbol-placement": "line", + "text-anchor": "center", + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Italic", + "Noto Sans Italic" + ], + "text-offset": [ + 0.8, + 0.8 + ], + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 11 + ], + [ + 15, + 12 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(205,84%,39%)", + "text-halo-blur": 0.5, + "text-halo-color": "hsla(0, 0%, 100%, 0.15)", + "text-halo-width": 1 + }, + "filter": [ + "==", + "class", + "ferry" + ] + }, + { + "id": "Oneway", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation", + "minzoom": 16, + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": [ + "match", + [ + "get", + "oneway" + ], + 1, + 0, + 0 + ], + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [ + 16, + 0.7 + ], + [ + 19, + 1 + ] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75, + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 0%, 65%)", + "icon-opacity": 0.5 + }, + "filter": [ + "all", + [ + "has", + "oneway" + ], + [ + "in", + "class", + "motorway", + "trunk", + "primary", + "secondary", + "tertiary", + "minor", + "service" + ] + ] + }, + { + "id": "Road labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 8, + "layout": { + "icon-allow-overlap": false, + "icon-ignore-placement": false, + "icon-keep-upright": false, + "symbol-placement": "line", + "symbol-spacing": [ + "step", + [ + "zoom" + ], + 250, + 21, + 1000 + ], + "text-allow-overlap": false, + "text-anchor": "center", + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-ignore-placement": false, + "text-justify": "center", + "text-max-width": 10, + "text-offset": [ + 0, + 0.15 + ], + "text-optional": false, + "text-size": { + "stops": [ + [ + 13, + 10 + ], + [ + 14, + 11 + ], + [ + 18, + 13 + ], + [ + 22, + 15 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 0%, 16%)", + "text-color": "hsl(0, 0%, 16%)", + "text-halo-blur": 0.5, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1 + }, + "metadata": {}, + "filter": [ + "all", + [ + "!in", + "subclass", + "gondola", + "cable_car" + ], + [ + "!in", + "class", + "ferry", + "service" + ] + ] + }, + { + "id": "Highway junction", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 16, + "layout": { + "icon-image": "exit_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-avoid-edges": true, + "symbol-placement": "point", + "symbol-spacing": 200, + "symbol-z-order": "auto", + "text-field": "{ref}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-size": 9, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(0,0%,21%)", + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1 + }, + "filter": [ + "all", + [ + ">", + "ref_length", + 0 + ], + [ + "==", + "$type", + "Point" + ], + [ + "==", + "subclass", + "junction" + ] + ] + }, + { + "id": "Highway shield", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 8, + "layout": { + "icon-image": "road_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-avoid-edges": true, + "symbol-placement": "line", + "symbol-spacing": { + "stops": [ + [ + 10, + 200 + ], + [ + 18, + 400 + ] + ] + }, + "text-field": "{ref}", + "text-font": [ + "match", + [ + "get", + "class" + ], + "motorway", + [ + "literal", + [ + "Roboto Bold", + "Noto Sans Bold" + ] + ], + [ + "literal", + [ + "Roboto Regular" + ] + ] + ], + "text-offset": [ + 0, + 0.05 + ], + "text-padding": 2, + "text-rotation-alignment": "viewport", + "text-size": 10, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 0%, 100%)", + "icon-halo-color": "hsl(0, 0%, 29%)", + "icon-halo-width": 1, + "text-color": "hsl(0, 0%, 29%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1 + }, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "!in", + "network", + "us-interstate", + "us-highway", + "us-state" + ], + [ + "!in", + "class", + "path" + ] + ] + }, + { + "id": "Highway shield (US)", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 7, + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1.1, + "symbol-avoid-edges": true, + "symbol-placement": { + "base": 1, + "stops": [ + [ + 7, + "point" + ], + [ + 7, + "line" + ], + [ + 8, + "line" + ] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": [ + "match", + [ + "get", + "class" + ], + "motorway", + [ + "literal", + [ + "Roboto Bold", + "Noto Sans Bold" + ] + ], + [ + "literal", + [ + "Roboto Regular", + "Noto Sans Regular" + ] + ] + ], + "text-offset": [ + 0, + 0.05 + ], + "text-rotation-alignment": "viewport", + "text-size": 9, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 0%, 100%)", + "icon-halo-color": "hsl(0, 0%, 29%)", + "icon-halo-width": 1, + "text-color": "hsl(0, 0%, 29%)", + "text-halo-color": "rgba(255, 255, 255, 1)", + "text-halo-width": 0 + }, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "network", + "us-highway", + "us-state" + ], + [ + "!in", + "class", + "path" + ] + ] + }, + { + "id": "Highway shield interstate top (US)", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 7, + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [ + 7, + "point" + ], + [ + 7, + "line" + ], + [ + 8, + "line" + ] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": [ + "match", + [ + "get", + "class" + ], + "motorway", + [ + "literal", + [ + "Roboto Bold", + "Noto Sans Bold" + ] + ], + [ + "literal", + [ + "Roboto Regular", + "Noto Sans Regular" + ] + ] + ], + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-size": 9, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(21, 100%, 45%)", + "icon-halo-color": "rgba(255, 255, 255, 1)", + "icon-halo-width": 1, + "icon-translate": [ + 0, + -4 + ], + "icon-translate-anchor": "viewport", + "text-color": "hsl(21, 100%, 45%)", + "text-halo-color": "rgba(255, 255, 255, 1)", + "text-halo-width": 0 + }, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "network", + "us-interstate" + ], + [ + "!in", + "class", + "path" + ] + ] + }, + { + "id": "Highway shield interstate (US)", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "transportation_name", + "minzoom": 7, + "layout": { + "icon-image": "{network}_{ref_length}", + "icon-rotation-alignment": "viewport", + "icon-size": 1, + "symbol-placement": { + "base": 1, + "stops": [ + [ + 7, + "point" + ], + [ + 7, + "line" + ], + [ + 8, + "line" + ] + ] + }, + "symbol-spacing": 200, + "text-field": "{ref}", + "text-font": [ + "match", + [ + "get", + "class" + ], + "motorway", + [ + "literal", + [ + "Roboto Bold", + "Noto Sans Bold" + ] + ], + [ + "literal", + [ + "Roboto Regular", + "Noto Sans Regular" + ] + ] + ], + "text-offset": [ + 0, + 0.1 + ], + "text-rotation-alignment": "viewport", + "text-size": 9, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(212, 79%, 42%)", + "icon-halo-color": "rgba(255, 255, 255, 1)", + "icon-halo-width": 1, + "text-color": "hsl(0, 0%, 100%)", + "text-halo-color": "rgba(255, 255, 255, 1)", + "text-halo-width": 0, + "text-translate": [ + 0, + -0.5 + ] + }, + "filter": [ + "all", + [ + "<=", + "ref_length", + 6 + ], + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "network", + "us-interstate" + ], + [ + "!in", + "class", + "path" + ] + ] + }, + { + "id": "Public", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 16, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + [ + "atm", + "bank", + "bbq", + "cemetery", + "courthouse", + "drinking_water", + "fire_station", + "fountain", + "hairdresser", + "office", + "post", + "prison", + "recycling", + "shower", + "telephone", + "toilets", + "townhall", + "town_hall" + ], + [ + "get", + "class" + ], + [ + "case", + [ + "has", + "class" + ], + "", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(51, 10%, 40%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "atm", + "bank", + "cemetery", + "courthouse", + "townhall", + "town_hall" + ], + 1, + 0 + ], + 17, + [ + "match", + [ + "get", + "class" + ], + [ + "atm", + "bank", + "cemetery", + "courthouse", + "fire_station", + "townhall", + "town_hall", + "post" + ], + 1, + 0 + ], + 18, + 1 + ], + "text-color": "hsl(51, 10%, 40%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "atm", + "bank", + "cemetery", + "courthouse", + "townhall", + "town_hall" + ], + 1, + 0 + ], + 17, + [ + "match", + [ + "get", + "class" + ], + [ + "atm", + "bank", + "cemetery", + "courthouse", + "fire_station", + "townhall", + "town_hall", + "post" + ], + 1, + 0 + ], + 18, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "atm", + "bank", + "bbq", + "cemetery", + "courthouse", + "drinking_water", + "fire_station", + "fountain", + "hairdresser", + "office", + "post", + "prison", + "recycling", + "shower", + "telephone", + "toilets", + "townhall", + "town_hall" + ] + ] + }, + { + "id": "Sport", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 16, + "layout": { + "icon-image": [ + "coalesce", + [ + "image", + [ + "to-string", + [ + "get", + "subclass" + ] + ] + ], + [ + "image", + [ + "get", + "class" + ] + ], + [ + "image", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(129, 65%, 30%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "playground", + "pitch", + "stadium", + "sports_hall", + "swimming_pool" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(129, 65%, 30%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "playground", + "pitch", + "stadium", + "sports_hall", + "swimming_pool" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "american_football", + "athletics", + "archery", + "baseball", + "basketball", + "climbing", + "equestrian", + "fitness", + "fitness_centre", + "golf", + "motor", + "multi", + "playground", + "pitch", + "running", + "sauna", + "soccer", + "sport", + "stadium", + "sports_centre", + "sports_hall", + "swimming", + "swimming_area", + "swimming_pool", + "tennis", + "volleyball", + "water_park" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Education", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 15, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + [ + "college", + "childcare", + "dancing_school", + "driving_school", + "kindergarten", + "school", + "university" + ], + [ + "get", + "class" + ], + [ + "case", + [ + "has", + "class" + ], + "", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(175, 50%, 40%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 15, + [ + "match", + [ + "get", + "subclass" + ], + [ + "university" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "school", + "university" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(175, 50%, 40%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 15, + [ + "match", + [ + "get", + "subclass" + ], + [ + "university" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "school", + "university" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "college", + "childcare", + "dancing_school", + "driving_school", + "kindergarten", + "school", + "university" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Tourism", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + [ + "apartment", + "aquarium", + "attraction", + "campsite", + "camp_site", + "caravan_site", + "castle", + "chalet", + "guest_house", + "hotel", + "hostel", + "information", + "lodging", + "motel", + "reservoir", + "ruins", + "theme_park", + "zoo" + ], + [ + "get", + "class" + ], + [ + "case", + [ + "has", + "class" + ], + "", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(283, 55%, 35%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "attraction" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "attraction", + "castle", + "hotel", + "zoo" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(283, 55%, 35%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "attraction" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "attraction", + "castle", + "hotel", + "zoo" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "apartment", + "aquarium", + "attraction", + "campsite", + "camp_site", + "caravan_site", + "castle", + "chalet", + "guest_house", + "hotel", + "hostel", + "information", + "lodging", + "motel", + "reservoir", + "ruins", + "theme_park", + "zoo" + ], + [ + "!=", + "subclass", + "board" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Culture", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "icon-image": [ + "coalesce", + [ + "image", + [ + "to-string", + [ + "get", + "subclass" + ] + ] + ], + [ + "image", + [ + "get", + "class" + ] + ], + [ + "image", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(315, 35%, 50%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "museum" + ], + 1, + 0 + ], + 15, + [ + "match", + [ + "get", + "class" + ], + [ + "cinema", + "art_gallery", + "museum", + "theatre" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "cinema", + "art_gallery", + "library", + "museum", + "place_of_worship", + "theatre" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(315, 35%, 50%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "museum" + ], + 1, + 0 + ], + 15, + [ + "match", + [ + "get", + "class" + ], + [ + "cinema", + "art_gallery", + "museum", + "theatre" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "cinema", + "art_gallery", + "library", + "museum", + "place_of_worship", + "theatre" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "art_gallery", + "archeological_site", + "cinema", + "community_centre", + "gallery", + "library", + "monastery", + "monument", + "museum", + "opera", + "place_of_worship", + "planetarium", + "theatre" + ], + [ + "!=", + "subclass", + "artwork" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Shopping", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "icon-image": [ + "coalesce", + [ + "image", + [ + "to-string", + [ + "get", + "subclass" + ] + ] + ], + [ + "image", + [ + "get", + "class" + ] + ], + [ + "image", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(18, 17%, 30%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "mall" + ], + 1, + 0 + ], + 15, + [ + "match", + [ + "get", + "subclass" + ], + [ + "mall", + "supermarket" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "bakery", + "chemist", + "mall", + "supermarket" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(18, 17%, 30%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "mall" + ], + 1, + 0 + ], + 15, + [ + "match", + [ + "get", + "subclass" + ], + [ + "mall", + "supermarket" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "bakery", + "chemist", + "mall", + "supermarket" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "all", + [ + "in", + "class", + "alcohol_shop", + "bakery", + "book", + "books", + "butcher", + "chemist", + "clothing_store", + "convenience", + "gift", + "grocery", + "laundry", + "mall", + "music", + "shop", + "supermarket" + ], + [ + "has", + "name" + ] + ] + ] + }, + { + "id": "Food", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "maxzoom": 22, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + [ + "bar", + "beer", + "cafe", + "fast_food", + "ice_cream", + "restaurant" + ], + [ + "get", + "class" + ], + [ + "biergarten", + "pub" + ], + "beer", + "", + [ + "get", + "class" + ], + [ + "food_court" + ], + "restaurant", + "dot" + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(18, 24%, 44%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 20 + ], + 1, + 0 + ], + 15, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 99 + ], + 1, + 0 + ], + 16, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 499 + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(18, 24%, 44%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 20 + ], + 1, + 0 + ], + 15, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 99 + ], + 1, + 0 + ], + 16, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 499 + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "bar", + "beer", + "biergarten", + "cafe", + "fast_food", + "food_court", + "ice_cream", + "pub", + "restaurant" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Transport", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + [ + "aerialway", + "bicycle", + "bicycle_parking", + "car", + "car_rental", + "car_repair", + "charging_station", + "cycle_barrier", + "ferry_terminal", + "fuel", + "harbor", + "motorcycle_parking", + "parking", + "parking_garage", + "parking_paid" + ], + [ + "get", + "class" + ], + [ + "case", + [ + "has", + "class" + ], + "dot", + "" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(215, 81%, 35%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "ferry_terminal", + "fuel", + "terminal" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "car", + "car_rental", + "car_repair", + "charging_station", + "ferry_terminal", + "fuel", + "harbor", + "heliport", + "highway_rest_area", + "terminal", + "toll" + ], + 1, + 0 + ], + 17, + [ + "match", + [ + "get", + "class" + ], + [ + "car", + "car_rental", + "car_repair", + "charging_station", + "ferry_terminal", + "fuel", + "harbor", + "heliport", + "highway_rest_area", + "parking", + "parking_garage", + "parking_paid", + "terminal", + "toll" + ], + 1, + 0 + ], + 18, + 1 + ], + "text-color": "hsl(215, 81%, 35%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "class" + ], + [ + "ferry_terminal", + "fuel", + "terminal" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "car", + "car_rental", + "car_repair", + "charging_station", + "ferry_terminal", + "fuel", + "harbor", + "heliport", + "highway_rest_area", + "terminal", + "toll" + ], + 1, + 0 + ], + 17, + [ + "match", + [ + "get", + "class" + ], + [ + "car", + "car_rental", + "car_repair", + "charging_station", + "ferry_terminal", + "fuel", + "harbor", + "heliport", + "highway_rest_area", + "parking", + "parking_garage", + "parking_paid", + "terminal", + "toll" + ], + 1, + 0 + ], + 18, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "bicycle", + "bicycle_parking", + "bicycle_rental", + "car", + "car_rental", + "car_repair", + "charging_station", + "ferry_terminal", + "fuel", + "harbor", + "heliport", + "highway_rest_area", + "motorcycle_parking", + "parking", + "parking_garage", + "parking_paid", + "scooter", + "terminal", + "toll" + ] + ] + }, + { + "id": "Park", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "icon-anchor": "center", + "icon-image": "park", + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(82, 83%, 25%)", + "icon-halo-blur": 0, + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 10 + ], + 1, + 0 + ], + 15, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 99 + ], + 1, + 0 + ], + 16, + 1 + ], + "text-color": "hsl(82, 83%, 25%)", + "text-halo-blur": 0, + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 10 + ], + 1, + 0 + ], + 15, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 99 + ], + 1, + 0 + ], + 16, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "class", + "park" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Healthcare", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 14, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + [ + "clinic", + "dentist", + "doctors", + "doctor", + "first_aid", + "hospital", + "pharmacy", + "veterinary" + ], + [ + "get", + "class" + ], + [ + "case", + [ + "has", + "class" + ], + "", + "dot" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 12, + 10 + ], + [ + 16, + 12 + ], + [ + 22, + 14 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(6, 96%, 35%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "hospital" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "clinic", + "hospital" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(6, 96%, 35%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "hospital" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + [ + "clinic", + "hospital" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "clinic", + "dentist", + "doctors", + "first_aid", + "hospital", + "pharmacy", + "veterinary" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Place labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "minzoom": 4, + "layout": { + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "center", + "text-field": "{name}", + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-letter-spacing": [ + "match", + [ + "get", + "class" + ], + [ + "suburb", + "neighborhood", + "neighbourhood", + "quarter", + "island" + ], + 0.2, + 0 + ], + "text-max-width": [ + "match", + [ + "get", + "class" + ], + [ + "island" + ], + 6, + 8 + ], + "text-offset": [ + 0, + 0 + ], + "text-padding": 2, + "text-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 3, + 11, + 8, + 13, + 11, + [ + "match", + [ + "get", + "class" + ], + "village", + 12, + [ + "suburb", + "neighbourhood", + "quarter", + "hamlet", + "isolated_dwelling" + ], + 9, + "island", + 8, + 12 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + "village", + 18, + [ + "suburb", + "neighbourhood", + "quarter", + "hamlet", + "isolated_dwelling" + ], + 15, + "island", + 11, + 16 + ] + ], + "text-transform": [ + "match", + [ + "get", + "class" + ], + [ + "suburb", + "neighborhood", + "neighbourhood", + "quarter", + "island" + ], + "uppercase", + "none" + ], + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(0,0%,25%)", + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1.2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 1, + 8, + [ + "match", + [ + "get", + "class" + ], + [ + "island" + ], + 0, + 1 + ], + 9, + [ + "match", + [ + "get", + "class" + ], + [ + "island" + ], + 1, + 1 + ] + ] + }, + "metadata": {}, + "filter": [ + "!in", + "class", + "continent", + "country", + "state", + "region", + "province", + "city", + "town", + "place" + ] + }, + { + "id": "Station", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "poi", + "minzoom": 12, + "layout": { + "icon-image": [ + "match", + [ + "get", + "subclass" + ], + [ + "bus_stop", + "subway" + ], + [ + "get", + "subclass" + ], + "bus_station", + "bus_stop", + "station", + "railway", + "tram_stop", + "tramway", + [ + "case", + [ + "has", + "subclass" + ], + "dot", + "" + ] + ], + "icon-size": 1, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "top", + "text-field": "{name}", + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-line-height": 0.9, + "text-max-width": 9, + "text-offset": [ + 0, + 0.9 + ], + "text-optional": true, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 13, + 11 + ], + [ + 16, + 12 + ], + [ + 22, + 16 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(215, 83%, 53%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.8, + 16, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": 2, + "icon-opacity": [ + "step", + [ + "zoom" + ], + 0, + 12, + [ + "match", + [ + "get", + "subclass" + ], + [ + "station" + ], + 1, + 0 + ], + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "station", + "subway", + "tram_stop" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "bus_stop", + "station", + "subway", + "tram_stop" + ], + 1, + 0 + ], + 17, + 1 + ], + "text-color": "hsl(215, 83%, 53%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 12, + 1, + 14, + 0.5, + 16, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 2, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 12, + [ + "match", + [ + "get", + "subclass" + ], + [ + "station" + ], + 1, + 0 + ], + 14, + [ + "match", + [ + "get", + "subclass" + ], + [ + "station", + "subway", + "tram_stop" + ], + 1, + 0 + ], + 16, + [ + "match", + [ + "get", + "subclass" + ], + [ + "bus_stop", + "station", + "subway", + "tram_stop" + ], + 1, + 0 + ], + 17, + 1 + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "in", + "class", + "bus", + "railway" + ], + [ + "has", + "name" + ] + ] + }, + { + "id": "Airport gate", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "aeroway", + "minzoom": 15, + "layout": { + "text-field": "{ref}", + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 10 + ], + [ + 22, + 18 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(0,0%,40%)", + "text-halo-blur": 0.5, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1 + }, + "filter": [ + "==", + "class", + "gate" + ] + }, + { + "id": "Airport", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "aerodrome_label", + "minzoom": 8, + "layout": { + "icon-image": [ + "match", + [ + "get", + "class" + ], + "international", + "airport", + "airfield" + ], + "icon-size": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 8, + 0.6, + 10, + [ + "match", + [ + "get", + "class" + ], + "international", + 0.8, + 0.6 + ], + 16, + [ + "match", + [ + "get", + "class" + ], + "international", + 1, + 0.8 + ] + ], + "text-anchor": "top", + "text-field": { + "stops": [ + [ + 8, + " " + ], + [ + 9, + "{iata}" + ], + [ + 12, + "{name:en}" + ] + ] + }, + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-line-height": 1.2, + "text-max-width": 9, + "text-offset": [ + 0, + 0.8 + ], + "text-optional": true, + "text-padding": 2, + "text-size": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 9, + 9, + 10, + [ + "match", + [ + "get", + "class" + ], + "international", + 10, + 7 + ], + 14, + [ + "match", + [ + "get", + "class" + ], + "international", + 13, + 11 + ] + ], + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(215, 83%, 53%)", + "icon-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 8, + 1, + 10, + 0.5, + 12, + 0 + ], + "icon-halo-color": "hsl(0, 0%, 100%)", + "icon-halo-width": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 8, + 1, + 12, + 2 + ], + "icon-opacity": 1, + "text-color": "hsl(215, 83%, 53%)", + "text-halo-blur": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 8, + 1, + 10, + 0.5, + 12, + 0 + ], + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 8, + 1, + 12, + 2 + ] + }, + "filter": [ + "all", + [ + "has", + "iata" + ], + [ + "!in", + "class", + "public" + ] + ] + }, + { + "id": "State labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "minzoom": 3, + "maxzoom": 9, + "layout": { + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-letter-spacing": 0.1, + "text-max-width": 8, + "text-padding": 2, + "text-size": { + "stops": [ + [ + 3, + 9 + ], + [ + 5, + 10 + ], + [ + 6, + 11 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(48,4%,44%)", + "text-halo-color": "hsla(0,0%,100%,0.75)", + "text-halo-width": 0.8, + "text-opacity": [ + "step", + [ + "zoom" + ], + 0, + 3, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 3 + ], + 1, + 0 + ], + 8, + [ + "case", + [ + "==", + [ + "get", + "rank" + ], + 0 + ], + 0, + 1 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "in", + "class", + "state", + "province" + ], + [ + "<=", + "rank", + 6 + ] + ] + }, + { + "id": "Town labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "minzoom": 4, + "maxzoom": 16, + "layout": { + "icon-allow-overlap": true, + "icon-image": { + "stops": [ + [ + 6, + "circle" + ], + [ + 12, + " " + ] + ] + }, + "icon-optional": false, + "icon-size": [ + "interpolate", + [ + "exponential", + 1 + ], + [ + "zoom" + ], + 6, + 0.3, + 14, + 0.4 + ], + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "bottom", + "text-field": [ + "coalesce", + [ + "get", + "name:en" + ], + [ + "get", + "name" + ] + ], + "text-font": [ + "Roboto Regular", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + -0.15 + ], + "text-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 6, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 12 + ], + 11, + 10 + ], + 9, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 15 + ], + 13, + 12 + ], + 16, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 15 + ], + 22, + 20 + ] + ], + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 20%, 99%)", + "icon-halo-color": "hsl(0, 0%, 29%)", + "icon-halo-width": 1, + "text-color": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 6, + "hsl(0,0%,20%)", + 12, + "hsl(0,0%,0%)" + ], + "text-halo-blur": 0.5, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1 + }, + "metadata": {}, + "filter": [ + "==", + "class", + "town" + ] + }, + { + "id": "City labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "minzoom": 4, + "maxzoom": 16, + "layout": { + "icon-allow-overlap": true, + "icon-image": [ + "step", + [ + "zoom" + ], + "circle", + 13, + "" + ], + "icon-optional": false, + "icon-size": 0.4, + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "bottom", + "text-field": "{name:en}", + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + -0.15 + ], + "text-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 4, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 2 + ], + 14, + 12 + ], + 8, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 4 + ], + 18, + 14 + ], + 12, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 4 + ], + 24, + 18 + ], + 16, + [ + "case", + [ + "<=", + [ + "get", + "rank" + ], + 4 + ], + 32, + 26 + ] + ], + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 0%, 100%)", + "icon-halo-color": "hsl(0, 0%, 29%)", + "icon-halo-width": 1, + "icon-opacity": 1, + "text-color": "hsl(0,0%,20%)", + "text-halo-blur": 0.5, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 0.8 + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + "!=", + "capital", + 2 + ] + ] + }, + { + "id": "Capital city labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "minzoom": 4, + "maxzoom": 16, + "layout": { + "icon-allow-overlap": true, + "icon-image": [ + "step", + [ + "zoom" + ], + "circle", + 13, + "" + ], + "icon-optional": false, + "icon-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 4, + 0.45, + 10, + 0.5, + 11, + 0.6 + ], + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-anchor": "bottom", + "text-field": "{name:en}", + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-max-width": 8, + "text-offset": [ + 0, + -0.15 + ], + "text-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 4, + 14, + 8, + 18, + 12, + 24, + 16, + 32 + ], + "visibility": "visible" + }, + "paint": { + "icon-color": "hsl(0, 0%, 100%)", + "icon-halo-color": "hsl(0, 0%, 29%)", + "icon-halo-width": 1, + "icon-opacity": 1, + "text-color": "hsl(0,0%,20%)", + "text-halo-blur": 0.5, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 0.8 + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + "==", + "capital", + 2 + ] + ] + }, + { + "id": "Country labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "minzoom": 1, + "maxzoom": 12, + "layout": { + "symbol-sort-key": [ + "to-number", + [ + "get", + "rank" + ] + ], + "text-allow-overlap": false, + "text-field": "{name:en}", + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-letter-spacing": 0.07, + "text-max-width": { + "stops": [ + [ + 1, + 5 + ], + [ + 5, + 8 + ] + ] + }, + "text-padding": 1, + "text-size": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 0, + 8, + 1, + 10, + 4, + [ + "case", + [ + ">", + [ + "get", + "rank" + ], + 2 + ], + 15, + 17 + ], + 8, + [ + "case", + [ + ">", + [ + "get", + "rank" + ], + 2 + ], + 19, + 23 + ] + ], + "text-transform": "none", + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(0, 0%, 20%)", + "text-halo-blur": 0.8, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1, + "text-opacity": [ + "interpolate", + [ + "linear", + 1 + ], + [ + "zoom" + ], + 4, + [ + "case", + [ + ">", + [ + "get", + "rank" + ], + 4 + ], + 0, + 1 + ], + 5.9, + [ + "case", + [ + ">", + [ + "get", + "rank" + ], + 4 + ], + 0, + 1 + ], + 6, + [ + "case", + [ + ">", + [ + "get", + "rank" + ], + 4 + ], + 1, + 1 + ] + ] + }, + "metadata": {}, + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + "has", + "iso_a2" + ], + [ + "!=", + "iso_a2", + "VA" + ] + ] + }, + { + "id": "Continent labels", + "type": "symbol", + "source": "maptiler_planet", + "source-layer": "place", + "maxzoom": 1, + "layout": { + "text-field": "{name:en}", + "text-font": [ + "Roboto Medium", + "Noto Sans Regular" + ], + "text-justify": "center", + "text-size": { + "stops": [ + [ + 0, + 12 + ], + [ + 2, + 13 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "hsl(0,0%,19%)", + "text-halo-blur": 1, + "text-halo-color": "hsl(0,0%,100%)", + "text-halo-width": 1 + }, + "metadata": {}, + "filter": [ + "==", + "class", + "continent" + ] + } + ], + "metadata": { + "maptiler:copyright": "You are licensed to use the style or its derivate for serving map tiles exclusively with MapTiler Server or MapTiler Cloud and in accordance with their licenses and terms. If you plan to use the style in a different way, contact us at sales@maptiler.com.", + "spaceColor": "hsl(203, 100%, 85%)" + }, + "glyphs": "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key=Tp9K7a8qzMTkBSzO4EZb", + "sprite": "https://api.maptiler.com/maps/streets-v2/sprite", + "bearing": 0, + "pitch": 0, + "center": [ + 0, + 0 + ], + "zoom": 1 +} \ No newline at end of file diff --git a/library/src/commonTest/composeResources/files/test_style_street_v2_sprite.json b/library/src/commonTest/composeResources/files/test_style_street_v2_sprite.json new file mode 100644 index 00000000..577fed5a --- /dev/null +++ b/library/src/commonTest/composeResources/files/test_style_street_v2_sprite.json @@ -0,0 +1 @@ +{"us-highway_1":{"width":23,"height":23,"x":0,"y":0,"pixelRatio":1.0,"sdf":true},"us-highway_2":{"width":23,"height":23,"x":23,"y":0,"pixelRatio":1.0,"sdf":true},"us-highway_3":{"width":27,"height":23,"x":0,"y":23,"pixelRatio":1.0,"sdf":true},"us-interstate_1":{"width":23,"height":23,"x":46,"y":0,"pixelRatio":1.0,"sdf":true},"us-interstate_2":{"width":23,"height":23,"x":69,"y":0,"pixelRatio":1.0,"sdf":true},"us-interstate_3":{"width":27,"height":23,"x":27,"y":23,"pixelRatio":1.0,"sdf":true},"us-interstate_4":{"width":29,"height":22,"x":54,"y":23,"pixelRatio":1.0,"sdf":true},"us-interstate_5":{"width":34,"height":22,"x":0,"y":46,"pixelRatio":1.0,"sdf":true},"aerialway":{"width":21,"height":21,"x":34,"y":46,"pixelRatio":1.0,"sdf":true},"airfield":{"width":21,"height":21,"x":55,"y":46,"pixelRatio":1.0,"sdf":true},"airport":{"width":21,"height":21,"x":0,"y":68,"pixelRatio":1.0,"sdf":true},"alcohol_shop":{"width":21,"height":21,"x":21,"y":68,"pixelRatio":1.0,"sdf":true},"american_football":{"width":21,"height":21,"x":42,"y":68,"pixelRatio":1.0,"sdf":true},"animal_shelter":{"width":21,"height":21,"x":63,"y":68,"pixelRatio":1.0,"sdf":true},"aquarium":{"width":21,"height":21,"x":84,"y":68,"pixelRatio":1.0,"sdf":true},"art_gallery":{"width":21,"height":21,"x":105,"y":68,"pixelRatio":1.0,"sdf":true},"atm":{"width":21,"height":21,"x":126,"y":68,"pixelRatio":1.0,"sdf":true},"attraction":{"width":21,"height":21,"x":147,"y":68,"pixelRatio":1.0,"sdf":true},"bakery":{"width":21,"height":21,"x":76,"y":46,"pixelRatio":1.0,"sdf":true},"bank":{"width":21,"height":21,"x":97,"y":46,"pixelRatio":1.0,"sdf":true},"bar":{"width":21,"height":21,"x":118,"y":46,"pixelRatio":1.0,"sdf":true},"barrier":{"width":21,"height":21,"x":139,"y":46,"pixelRatio":1.0,"sdf":true},"baseball":{"width":21,"height":21,"x":160,"y":46,"pixelRatio":1.0,"sdf":true},"basketball":{"width":21,"height":21,"x":92,"y":0,"pixelRatio":1.0,"sdf":true},"bbq":{"width":21,"height":21,"x":113,"y":0,"pixelRatio":1.0,"sdf":true},"beach":{"width":21,"height":21,"x":134,"y":0,"pixelRatio":1.0,"sdf":true},"beer":{"width":21,"height":21,"x":155,"y":0,"pixelRatio":1.0,"sdf":true},"bicycle":{"width":21,"height":21,"x":83,"y":23,"pixelRatio":1.0,"sdf":true},"bicycle_parking":{"width":21,"height":21,"x":104,"y":23,"pixelRatio":1.0,"sdf":true},"bicycle_rental":{"width":21,"height":21,"x":125,"y":23,"pixelRatio":1.0,"sdf":true},"bicycle_share":{"width":21,"height":21,"x":146,"y":23,"pixelRatio":1.0,"sdf":true},"billiards":{"width":21,"height":21,"x":0,"y":89,"pixelRatio":1.0,"sdf":true},"board":{"width":21,"height":21,"x":21,"y":89,"pixelRatio":1.0,"sdf":true},"bollard":{"width":21,"height":21,"x":42,"y":89,"pixelRatio":1.0,"sdf":true},"bowling":{"width":21,"height":21,"x":63,"y":89,"pixelRatio":1.0,"sdf":true},"buddhist":{"width":21,"height":21,"x":84,"y":89,"pixelRatio":1.0,"sdf":true},"building":{"width":21,"height":21,"x":105,"y":89,"pixelRatio":1.0,"sdf":true},"bulldozer":{"width":21,"height":21,"x":126,"y":89,"pixelRatio":1.0,"sdf":true},"bus_guided":{"width":21,"height":21,"x":147,"y":89,"pixelRatio":1.0,"sdf":true},"bus_stop":{"width":21,"height":21,"x":0,"y":110,"pixelRatio":1.0,"sdf":true},"bus_trolley":{"width":21,"height":21,"x":21,"y":110,"pixelRatio":1.0,"sdf":true},"butcher":{"width":21,"height":21,"x":42,"y":110,"pixelRatio":1.0,"sdf":true},"cafe":{"width":21,"height":21,"x":63,"y":110,"pixelRatio":1.0,"sdf":true},"camper_trailer":{"width":21,"height":21,"x":84,"y":110,"pixelRatio":1.0,"sdf":true},"campfire":{"width":21,"height":21,"x":105,"y":110,"pixelRatio":1.0,"sdf":true},"campsite":{"width":21,"height":21,"x":126,"y":110,"pixelRatio":1.0,"sdf":true},"car":{"width":21,"height":21,"x":147,"y":110,"pixelRatio":1.0,"sdf":true},"car_rental":{"width":21,"height":21,"x":0,"y":131,"pixelRatio":1.0,"sdf":true},"car_repair":{"width":21,"height":21,"x":21,"y":131,"pixelRatio":1.0,"sdf":true},"casino":{"width":21,"height":21,"x":42,"y":131,"pixelRatio":1.0,"sdf":true},"castle":{"width":21,"height":21,"x":63,"y":131,"pixelRatio":1.0,"sdf":true},"caution":{"width":21,"height":21,"x":84,"y":131,"pixelRatio":1.0,"sdf":true},"cemetery":{"width":21,"height":21,"x":105,"y":131,"pixelRatio":1.0,"sdf":true},"charging_station":{"width":21,"height":21,"x":126,"y":131,"pixelRatio":1.0,"sdf":true},"christian":{"width":21,"height":21,"x":147,"y":131,"pixelRatio":1.0,"sdf":true},"cinema":{"width":21,"height":21,"x":0,"y":152,"pixelRatio":1.0,"sdf":true},"circle":{"width":21,"height":21,"x":21,"y":152,"pixelRatio":1.0,"sdf":true},"circle-dot":{"width":21,"height":21,"x":42,"y":152,"pixelRatio":1.0,"sdf":true},"circle-stroke":{"width":21,"height":21,"x":63,"y":152,"pixelRatio":1.0,"sdf":true},"city":{"width":21,"height":21,"x":84,"y":152,"pixelRatio":1.0,"sdf":true},"clothing_store":{"width":21,"height":21,"x":105,"y":152,"pixelRatio":1.0,"sdf":true},"college":{"width":21,"height":21,"x":126,"y":152,"pixelRatio":1.0,"sdf":true},"commercial":{"width":21,"height":21,"x":147,"y":152,"pixelRatio":1.0,"sdf":true},"communications_tower":{"width":21,"height":21,"x":168,"y":68,"pixelRatio":1.0,"sdf":true},"confectionery":{"width":21,"height":21,"x":189,"y":68,"pixelRatio":1.0,"sdf":true},"construction":{"width":21,"height":21,"x":210,"y":68,"pixelRatio":1.0,"sdf":true},"convenience":{"width":21,"height":21,"x":231,"y":68,"pixelRatio":1.0,"sdf":true},"cricket":{"width":21,"height":21,"x":252,"y":68,"pixelRatio":1.0,"sdf":true},"cross":{"width":21,"height":21,"x":273,"y":68,"pixelRatio":1.0,"sdf":true},"cycle_barrier":{"width":21,"height":21,"x":294,"y":68,"pixelRatio":1.0,"sdf":true},"dam":{"width":21,"height":21,"x":315,"y":68,"pixelRatio":1.0,"sdf":true},"danger":{"width":21,"height":21,"x":336,"y":68,"pixelRatio":1.0,"sdf":true},"defibrillator":{"width":21,"height":21,"x":168,"y":89,"pixelRatio":1.0,"sdf":true},"dentist":{"width":21,"height":21,"x":189,"y":89,"pixelRatio":1.0,"sdf":true},"diamond":{"width":21,"height":21,"x":210,"y":89,"pixelRatio":1.0,"sdf":true},"diamond_stroked":{"width":21,"height":21,"x":231,"y":89,"pixelRatio":1.0,"sdf":true},"diplomatic":{"width":21,"height":21,"x":252,"y":89,"pixelRatio":1.0,"sdf":true},"doctors":{"width":21,"height":21,"x":273,"y":89,"pixelRatio":1.0,"sdf":true},"dog_park":{"width":21,"height":21,"x":294,"y":89,"pixelRatio":1.0,"sdf":true},"drinking_water":{"width":21,"height":21,"x":315,"y":89,"pixelRatio":1.0,"sdf":true},"elevator":{"width":21,"height":21,"x":336,"y":89,"pixelRatio":1.0,"sdf":true},"emergency_phone":{"width":21,"height":21,"x":168,"y":110,"pixelRatio":1.0,"sdf":true},"entrance":{"width":21,"height":21,"x":189,"y":110,"pixelRatio":1.0,"sdf":true},"farm":{"width":21,"height":21,"x":210,"y":110,"pixelRatio":1.0,"sdf":true},"fast_food":{"width":21,"height":21,"x":231,"y":110,"pixelRatio":1.0,"sdf":true},"fence":{"width":21,"height":21,"x":252,"y":110,"pixelRatio":1.0,"sdf":true},"ferry_terminal":{"width":21,"height":21,"x":273,"y":110,"pixelRatio":1.0,"sdf":true},"fire_station":{"width":21,"height":21,"x":294,"y":110,"pixelRatio":1.0,"sdf":true},"fitness_centre":{"width":21,"height":21,"x":315,"y":110,"pixelRatio":1.0,"sdf":true},"florist":{"width":21,"height":21,"x":336,"y":110,"pixelRatio":1.0,"sdf":true},"fountain":{"width":21,"height":21,"x":168,"y":131,"pixelRatio":1.0,"sdf":true},"fuel":{"width":21,"height":21,"x":189,"y":131,"pixelRatio":1.0,"sdf":true},"furniture":{"width":21,"height":21,"x":210,"y":131,"pixelRatio":1.0,"sdf":true},"gaming":{"width":21,"height":21,"x":231,"y":131,"pixelRatio":1.0,"sdf":true},"garden":{"width":21,"height":21,"x":252,"y":131,"pixelRatio":1.0,"sdf":true},"garden_centre":{"width":21,"height":21,"x":273,"y":131,"pixelRatio":1.0,"sdf":true},"gate":{"width":21,"height":21,"x":294,"y":131,"pixelRatio":1.0,"sdf":true},"gift":{"width":21,"height":21,"x":315,"y":131,"pixelRatio":1.0,"sdf":true},"globe":{"width":21,"height":21,"x":336,"y":131,"pixelRatio":1.0,"sdf":true},"golf":{"width":21,"height":21,"x":168,"y":152,"pixelRatio":1.0,"sdf":true},"gondola":{"width":21,"height":21,"x":189,"y":152,"pixelRatio":1.0,"sdf":true},"grocery":{"width":21,"height":21,"x":210,"y":152,"pixelRatio":1.0,"sdf":true},"guidepost":{"width":21,"height":21,"x":231,"y":152,"pixelRatio":1.0,"sdf":true},"hairdresser":{"width":21,"height":21,"x":252,"y":152,"pixelRatio":1.0,"sdf":true},"harbor":{"width":21,"height":21,"x":273,"y":152,"pixelRatio":1.0,"sdf":true},"hardware":{"width":21,"height":21,"x":294,"y":152,"pixelRatio":1.0,"sdf":true},"heart":{"width":21,"height":21,"x":315,"y":152,"pixelRatio":1.0,"sdf":true},"heliport":{"width":21,"height":21,"x":336,"y":152,"pixelRatio":1.0,"sdf":true},"highway_rest_area":{"width":21,"height":21,"x":181,"y":46,"pixelRatio":1.0,"sdf":true},"hinduist":{"width":21,"height":21,"x":202,"y":46,"pixelRatio":1.0,"sdf":true},"historic":{"width":21,"height":21,"x":223,"y":46,"pixelRatio":1.0,"sdf":true},"home":{"width":21,"height":21,"x":244,"y":46,"pixelRatio":1.0,"sdf":true},"horse_riding":{"width":21,"height":21,"x":265,"y":46,"pixelRatio":1.0,"sdf":true},"hospital":{"width":21,"height":21,"x":286,"y":46,"pixelRatio":1.0,"sdf":true},"hot_spring":{"width":21,"height":21,"x":307,"y":46,"pixelRatio":1.0,"sdf":true},"ice_cream":{"width":21,"height":21,"x":328,"y":46,"pixelRatio":1.0,"sdf":true},"industry":{"width":21,"height":21,"x":176,"y":0,"pixelRatio":1.0,"sdf":true},"information":{"width":21,"height":21,"x":197,"y":0,"pixelRatio":1.0,"sdf":true},"jewelry":{"width":21,"height":21,"x":218,"y":0,"pixelRatio":1.0,"sdf":true},"jewish":{"width":21,"height":21,"x":239,"y":0,"pixelRatio":1.0,"sdf":true},"karaoke":{"width":21,"height":21,"x":260,"y":0,"pixelRatio":1.0,"sdf":true},"landmark":{"width":21,"height":21,"x":281,"y":0,"pixelRatio":1.0,"sdf":true},"laundry":{"width":21,"height":21,"x":302,"y":0,"pixelRatio":1.0,"sdf":true},"library":{"width":21,"height":21,"x":323,"y":0,"pixelRatio":1.0,"sdf":true},"lift_gate":{"width":21,"height":21,"x":344,"y":0,"pixelRatio":1.0,"sdf":true},"light_rail":{"width":21,"height":21,"x":167,"y":23,"pixelRatio":1.0,"sdf":true},"lighthouse":{"width":21,"height":21,"x":188,"y":23,"pixelRatio":1.0,"sdf":true},"lodging":{"width":21,"height":21,"x":209,"y":23,"pixelRatio":1.0,"sdf":true},"marker":{"width":21,"height":21,"x":251,"y":23,"pixelRatio":1.0,"sdf":true},"marker_stroked":{"width":21,"height":21,"x":272,"y":23,"pixelRatio":1.0,"sdf":true},"mobile_phone":{"width":21,"height":21,"x":293,"y":23,"pixelRatio":1.0,"sdf":true},"monorail":{"width":21,"height":21,"x":314,"y":23,"pixelRatio":1.0,"sdf":true},"monument":{"width":21,"height":21,"x":335,"y":23,"pixelRatio":1.0,"sdf":true},"motorcycle":{"width":21,"height":21,"x":0,"y":173,"pixelRatio":1.0,"sdf":true},"motorcycle_parking":{"width":21,"height":21,"x":21,"y":173,"pixelRatio":1.0,"sdf":true},"motorcycle_rental":{"width":21,"height":21,"x":42,"y":173,"pixelRatio":1.0,"sdf":true},"motorcycle_repair":{"width":21,"height":21,"x":63,"y":173,"pixelRatio":1.0,"sdf":true},"museum":{"width":21,"height":21,"x":84,"y":173,"pixelRatio":1.0,"sdf":true},"music":{"width":21,"height":21,"x":105,"y":173,"pixelRatio":1.0,"sdf":true},"muslim":{"width":21,"height":21,"x":126,"y":173,"pixelRatio":1.0,"sdf":true},"natural":{"width":21,"height":21,"x":147,"y":173,"pixelRatio":1.0,"sdf":true},"noodle":{"width":21,"height":21,"x":168,"y":173,"pixelRatio":1.0,"sdf":true},"obelisk":{"width":21,"height":21,"x":189,"y":173,"pixelRatio":1.0,"sdf":true},"observation_tower":{"width":21,"height":21,"x":210,"y":173,"pixelRatio":1.0,"sdf":true},"oneway":{"width":21,"height":21,"x":231,"y":173,"pixelRatio":1.0,"sdf":true},"optician":{"width":21,"height":21,"x":252,"y":173,"pixelRatio":1.0,"sdf":true},"outdoor":{"width":21,"height":21,"x":273,"y":173,"pixelRatio":1.0,"sdf":true},"paint":{"width":21,"height":21,"x":294,"y":173,"pixelRatio":1.0,"sdf":true},"park":{"width":21,"height":21,"x":315,"y":173,"pixelRatio":1.0,"sdf":true},"parking":{"width":21,"height":21,"x":336,"y":173,"pixelRatio":1.0,"sdf":true},"parking_garage":{"width":21,"height":21,"x":0,"y":194,"pixelRatio":1.0,"sdf":true},"parking_paid":{"width":21,"height":21,"x":21,"y":194,"pixelRatio":1.0,"sdf":true},"peak":{"width":21,"height":21,"x":42,"y":194,"pixelRatio":1.0,"sdf":true},"perfumery":{"width":21,"height":21,"x":63,"y":194,"pixelRatio":1.0,"sdf":true},"pharmacy":{"width":21,"height":21,"x":84,"y":194,"pixelRatio":1.0,"sdf":true},"picnic_site":{"width":21,"height":21,"x":105,"y":194,"pixelRatio":1.0,"sdf":true},"pin":{"width":21,"height":21,"x":126,"y":194,"pixelRatio":1.0,"sdf":true},"pipe":{"width":21,"height":21,"x":147,"y":194,"pixelRatio":1.0,"sdf":true},"pitch":{"width":21,"height":21,"x":168,"y":194,"pixelRatio":1.0,"sdf":true},"pizza":{"width":21,"height":21,"x":189,"y":194,"pixelRatio":1.0,"sdf":true},"place_of_worship":{"width":21,"height":21,"x":210,"y":194,"pixelRatio":1.0,"sdf":true},"playground":{"width":21,"height":21,"x":231,"y":194,"pixelRatio":1.0,"sdf":true},"police":{"width":21,"height":21,"x":252,"y":194,"pixelRatio":1.0,"sdf":true},"post":{"width":21,"height":21,"x":273,"y":194,"pixelRatio":1.0,"sdf":true},"prison":{"width":21,"height":21,"x":294,"y":194,"pixelRatio":1.0,"sdf":true},"racetrack":{"width":21,"height":21,"x":315,"y":194,"pixelRatio":1.0,"sdf":true},"radiation":{"width":21,"height":21,"x":336,"y":194,"pixelRatio":1.0,"sdf":true},"railway":{"width":21,"height":21,"x":0,"y":215,"pixelRatio":1.0,"sdf":true},"ranger_station":{"width":21,"height":21,"x":21,"y":215,"pixelRatio":1.0,"sdf":true},"recycling":{"width":21,"height":21,"x":42,"y":215,"pixelRatio":1.0,"sdf":true},"restaurant":{"width":21,"height":21,"x":63,"y":215,"pixelRatio":1.0,"sdf":true},"road_accident":{"width":21,"height":21,"x":84,"y":215,"pixelRatio":1.0,"sdf":true},"roadblock":{"width":21,"height":21,"x":105,"y":215,"pixelRatio":1.0,"sdf":true},"rocket":{"width":21,"height":21,"x":126,"y":215,"pixelRatio":1.0,"sdf":true},"school":{"width":21,"height":21,"x":147,"y":215,"pixelRatio":1.0,"sdf":true},"school_bus":{"width":21,"height":21,"x":168,"y":215,"pixelRatio":1.0,"sdf":true},"scooter":{"width":21,"height":21,"x":189,"y":215,"pixelRatio":1.0,"sdf":true},"sculpture":{"width":21,"height":21,"x":210,"y":215,"pixelRatio":1.0,"sdf":true},"seafood":{"width":21,"height":21,"x":231,"y":215,"pixelRatio":1.0,"sdf":true},"shinto":{"width":21,"height":21,"x":252,"y":215,"pixelRatio":1.0,"sdf":true},"shoes":{"width":21,"height":21,"x":273,"y":215,"pixelRatio":1.0,"sdf":true},"shop":{"width":21,"height":21,"x":294,"y":215,"pixelRatio":1.0,"sdf":true},"sikh":{"width":21,"height":21,"x":315,"y":215,"pixelRatio":1.0,"sdf":true},"skateboard":{"width":21,"height":21,"x":336,"y":215,"pixelRatio":1.0,"sdf":true},"snow":{"width":21,"height":21,"x":0,"y":236,"pixelRatio":1.0,"sdf":true},"soccer":{"width":21,"height":21,"x":21,"y":236,"pixelRatio":1.0,"sdf":true},"spring":{"width":21,"height":21,"x":42,"y":236,"pixelRatio":1.0,"sdf":true},"stadium":{"width":21,"height":21,"x":63,"y":236,"pixelRatio":1.0,"sdf":true},"star":{"width":21,"height":21,"x":84,"y":236,"pixelRatio":1.0,"sdf":true},"star_stroked":{"width":21,"height":21,"x":105,"y":236,"pixelRatio":1.0,"sdf":true},"statue":{"width":21,"height":21,"x":126,"y":236,"pixelRatio":1.0,"sdf":true},"subway":{"width":21,"height":21,"x":147,"y":236,"pixelRatio":1.0,"sdf":true},"suitcase":{"width":21,"height":21,"x":168,"y":236,"pixelRatio":1.0,"sdf":true},"sushi":{"width":21,"height":21,"x":189,"y":236,"pixelRatio":1.0,"sdf":true},"swimming":{"width":21,"height":21,"x":210,"y":236,"pixelRatio":1.0,"sdf":true},"swimming_pool":{"width":21,"height":21,"x":231,"y":236,"pixelRatio":1.0,"sdf":true},"tako":{"width":21,"height":21,"x":252,"y":236,"pixelRatio":1.0,"sdf":true},"taoist":{"width":21,"height":21,"x":273,"y":236,"pixelRatio":1.0,"sdf":true},"taxi":{"width":21,"height":21,"x":294,"y":236,"pixelRatio":1.0,"sdf":true},"teahouse":{"width":21,"height":21,"x":315,"y":236,"pixelRatio":1.0,"sdf":true},"telephone":{"width":21,"height":21,"x":336,"y":236,"pixelRatio":1.0,"sdf":true},"tennis":{"width":21,"height":21,"x":0,"y":257,"pixelRatio":1.0,"sdf":true},"terminal":{"width":21,"height":21,"x":21,"y":257,"pixelRatio":1.0,"sdf":true},"theatre":{"width":21,"height":21,"x":42,"y":257,"pixelRatio":1.0,"sdf":true},"theme_park":{"width":21,"height":21,"x":63,"y":257,"pixelRatio":1.0,"sdf":true},"toilets":{"width":21,"height":21,"x":84,"y":257,"pixelRatio":1.0,"sdf":true},"toll":{"width":21,"height":21,"x":105,"y":257,"pixelRatio":1.0,"sdf":true},"town":{"width":21,"height":21,"x":126,"y":257,"pixelRatio":1.0,"sdf":true},"town_hall":{"width":21,"height":21,"x":147,"y":257,"pixelRatio":1.0,"sdf":true},"tram_stop":{"width":21,"height":21,"x":168,"y":257,"pixelRatio":1.0,"sdf":true},"tramway":{"width":21,"height":21,"x":189,"y":257,"pixelRatio":1.0,"sdf":true},"transit":{"width":21,"height":21,"x":210,"y":257,"pixelRatio":1.0,"sdf":true},"truck":{"width":21,"height":21,"x":231,"y":257,"pixelRatio":1.0,"sdf":true},"tunnel":{"width":21,"height":21,"x":252,"y":257,"pixelRatio":1.0,"sdf":true},"veterinary":{"width":21,"height":21,"x":273,"y":257,"pixelRatio":1.0,"sdf":true},"viewpoint":{"width":21,"height":21,"x":294,"y":257,"pixelRatio":1.0,"sdf":true},"village":{"width":21,"height":21,"x":315,"y":257,"pixelRatio":1.0,"sdf":true},"volcano":{"width":21,"height":21,"x":336,"y":257,"pixelRatio":1.0,"sdf":true},"volleyball":{"width":21,"height":21,"x":0,"y":278,"pixelRatio":1.0,"sdf":true},"warehouse":{"width":21,"height":21,"x":21,"y":278,"pixelRatio":1.0,"sdf":true},"waste_basket":{"width":21,"height":21,"x":42,"y":278,"pixelRatio":1.0,"sdf":true},"watches":{"width":21,"height":21,"x":63,"y":278,"pixelRatio":1.0,"sdf":true},"water":{"width":21,"height":21,"x":84,"y":278,"pixelRatio":1.0,"sdf":true},"water_park":{"width":21,"height":21,"x":105,"y":278,"pixelRatio":1.0,"sdf":true},"water_tower":{"width":21,"height":21,"x":126,"y":278,"pixelRatio":1.0,"sdf":true},"watermill":{"width":21,"height":21,"x":147,"y":278,"pixelRatio":1.0,"sdf":true},"wetland":{"width":21,"height":21,"x":168,"y":278,"pixelRatio":1.0,"sdf":true},"wheelchair":{"width":21,"height":21,"x":189,"y":278,"pixelRatio":1.0,"sdf":true},"windmill":{"width":21,"height":21,"x":210,"y":278,"pixelRatio":1.0,"sdf":true},"wine":{"width":21,"height":21,"x":231,"y":278,"pixelRatio":1.0,"sdf":true},"zoo":{"width":21,"height":21,"x":252,"y":278,"pixelRatio":1.0,"sdf":true},"road_1":{"width":20,"height":20,"x":273,"y":278,"pixelRatio":1.0,"sdf":true},"us-state_1":{"width":23,"height":20,"x":293,"y":278,"pixelRatio":1.0,"sdf":true},"us-state_2":{"width":28,"height":20,"x":316,"y":278,"pixelRatio":1.0,"sdf":true},"us-state_3":{"width":33,"height":20,"x":0,"y":299,"pixelRatio":1.0,"sdf":true},"us-state_4":{"width":38,"height":20,"x":33,"y":299,"pixelRatio":1.0,"sdf":true},"us-state_5":{"width":43,"height":20,"x":71,"y":299,"pixelRatio":1.0,"sdf":true},"us-state_6":{"width":48,"height":20,"x":114,"y":299,"pixelRatio":1.0,"sdf":true},"road_2":{"width":24,"height":19,"x":162,"y":299,"pixelRatio":1.0,"sdf":true},"road_3":{"width":30,"height":19,"x":186,"y":299,"pixelRatio":1.0,"sdf":true},"road_4":{"width":36,"height":19,"x":216,"y":299,"pixelRatio":1.0,"sdf":true},"road_5":{"width":40,"height":19,"x":252,"y":299,"pixelRatio":1.0,"sdf":true},"road_6":{"width":44,"height":19,"x":292,"y":299,"pixelRatio":1.0,"sdf":true},"golf_green":{"width":17,"height":17,"x":336,"y":299,"pixelRatio":1.0,"sdf":true},"hut":{"width":18,"height":16,"x":344,"y":278,"pixelRatio":1.0,"sdf":true},"shelter":{"width":18,"height":16,"x":349,"y":46,"pixelRatio":1.0,"sdf":true},"triangle":{"width":18,"height":16,"x":0,"y":319,"pixelRatio":1.0,"sdf":true},"triangle_stroked":{"width":17,"height":16,"x":18,"y":319,"pixelRatio":1.0,"sdf":true},"square":{"width":14,"height":14,"x":35,"y":319,"pixelRatio":1.0,"sdf":true},"square_stroked":{"width":14,"height":14,"x":49,"y":319,"pixelRatio":1.0,"sdf":true},"dot":{"width":13,"height":13,"x":63,"y":319,"pixelRatio":1.0,"sdf":true},"bench":{"width":17,"height":11,"x":76,"y":319,"pixelRatio":1.0,"sdf":true}} \ No newline at end of file diff --git a/library/src/commonTest/composeResources/files/test_style_street_v2_sprite.png b/library/src/commonTest/composeResources/files/test_style_street_v2_sprite.png new file mode 100644 index 00000000..f7d1bd87 Binary files /dev/null and b/library/src/commonTest/composeResources/files/test_style_street_v2_sprite.png differ diff --git a/library/src/commonTest/composeResources/files/test_style_swisstopo.json b/library/src/commonTest/composeResources/files/test_style_swisstopo.json new file mode 100644 index 00000000..e2fb370e --- /dev/null +++ b/library/src/commonTest/composeResources/files/test_style_swisstopo.json @@ -0,0 +1 @@ +{"version": 8, "id": "0197406a-5d2f-7168-bee5-f191641499b7", "name": "lightbasemap_v1.19.0", "sources": {"relief_v1.0.0": {"url": "https://vectortiles.geo.admin.ch/tiles/ch.swisstopo.relief.vt/v1.0.0/tiles.json", "type": "vector"}, "base_v1.0.0": {"url": "https://vectortiles.geo.admin.ch/tiles/ch.swisstopo.base.vt/v1.0.0/tiles.json", "type": "vector"}}, "layers": [{"id": "background", "type": "background", "layout": {"visibility": "visible"}, "paint": {"background-color": "rgba(252, 252, 252, 1)"}}, {"id": "hillshade_grey", "type": "fill", "source": "relief_v1.0.0", "source-layer": "hillshade", "layout": {"visibility": "visible"}, "paint": {"fill-color": ["match", ["get", "luminosity"], -15, "rgb(200,210,213)", -14, "rgb(203,213,216)", -13, "rgb(207,215,218)", -12, "rgb(210,218,221)", -11, "rgb(213,221,224)", -10, "rgb(217,224,226)", -9, "rgb(220,227,229)", -8, "rgb(224,230,231)", -7, "rgb(227,232,234)", -6, "rgb(231,235,237)", -5, "rgb(234,238,239)", -4, "rgb(238,241,242)", -3, "rgb(241,244,245)", -2, "rgb(245,247,247)", -1, "rgb(248,249,250)", "rgb(252, 252, 252)"], "fill-opacity": {"stops": [[1, 0.5], [8, 1]]}}, "filter": ["all", ["<", "luminosity", 0]]}, {"id": "hillshade_yellow", "type": "fill", "source": "relief_v1.0.0", "source-layer": "hillshade", "minzoom": 6.0, "layout": {"visibility": "visible"}, "paint": {"fill-color": "rgba(255, 217, 5, 1)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 9, 0.015, 11, 0.02]}, "filter": ["all", [">", "luminosity", 0], ["!in", "mask", "rock", "ice", "scree"]]}, {"id": "scree_z17", "type": "fill", "source": "relief_v1.0.0", "source-layer": "scree", "minzoom": 17.0, "layout": {"visibility": "visible"}, "paint": {"fill-opacity": ["interpolate", ["linear"], ["zoom"], 9, 0, 11, 0.2, 14, 0.25], "fill-pattern": ["match", ["get", "weight"], 15, "scree_very_large_1", 10, "scree_very_large_2", 5, "scree_very_large_3", 1, "scree_very_large_4", ""]}}, {"id": "scree_z15", "type": "fill", "source": "relief_v1.0.0", "source-layer": "scree", "minzoom": 15.0, "maxzoom": 17.0, "layout": {"visibility": "visible"}, "paint": {"fill-opacity": ["interpolate", ["linear"], ["zoom"], 9, 0, 11, 0.2, 14, 0.25], "fill-pattern": ["match", ["get", "weight"], 15, "scree_large_1", 10, "scree_large_2", 5, "scree_large_3", 1, "scree_large_4", ""]}}, {"id": "scree_z13", "type": "fill", "source": "relief_v1.0.0", "source-layer": "scree", "minzoom": 13.0, "maxzoom": 15.0, "layout": {"visibility": "visible"}, "paint": {"fill-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 0.2, 14, 0.25], "fill-pattern": ["match", ["get", "weight"], 15, "scree_medium_1", 10, "scree_medium_2", 5, "scree_medium_3", 1, "scree_medium_4", ""]}}, {"id": "scree_z11", "type": "fill", "source": "relief_v1.0.0", "source-layer": "scree", "minzoom": 11.0, "maxzoom": 13.0, "layout": {"visibility": "visible"}, "paint": {"fill-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12, 0.2, 14, 0.25], "fill-pattern": ["match", ["get", "weight"], 15, "scree_small_1", 10, "scree_small_2", 5, "scree_small_3", 1, "scree_small_4", ""]}}, {"id": "contour_line", "type": "line", "source": "base_v1.0.0", "source-layer": "contour_line", "minzoom": 13.0, "layout": {"visibility": "visible"}, "paint": {"line-blur": 0.4, "line-color": ["match", ["get", "class"], ["ice", "water"], "rgb(0,136,208)", ["scree"], "rgb(0,0,0)", "rgb(191,138,64)"], "line-width": ["interpolate", ["exponential", 1], ["zoom"], 12, ["match", ["%", ["to-number", ["get", "ele"]], 100], 0, 0.75, 0], 14, ["match", ["%", ["to-number", ["get", "ele"]], 100], 0, 1, 0], 15, ["match", ["%", ["to-number", ["get", "ele"]], 100], 0, 1.5, [20, 40, 60, 80], 0.75, 0], 18, ["match", ["%", ["to-number", ["get", "ele"]], 100], 0, 2, [20, 40, 60, 80], 1.5, 1]], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, ["match", ["get", "class"], ["scree", "ice", "water"], 0.25, 0.45], 14, ["match", ["get", "class"], ["scree", "ice", "water"], 0.3, 0.55]]}, "metadata": {"maputnik:comment": "color: rgb(191,138,64)"}, "filter": ["all", ["!in", "class", "rock", "ice", "water"]]}, {"id": "landcover", "type": "fill", "source": "base_v1.0.0", "source-layer": "landcover", "minzoom": 5.0, "layout": {"visibility": "visible"}, "paint": {"fill-color": ["match", ["get", "class"], ["forest", "wood"], "rgb(186, 210, 172)", ["ice", "glacier"], "rgb(205, 232, 244)", "wetland", "rgb(204, 229, 245)", "sand", "rgb(240, 218, 188)", "rgb(215, 224, 209)"], "fill-opacity": ["interpolate", ["exponential", 1.5], ["zoom"], 5, 0, 6, ["match", ["get", "class"], ["ice", "glacier"], 0.3, 0], 10, ["match", ["get", "class"], ["ice", "glacier"], 0.2, 0], 11, ["match", ["get", "class"], ["ice", "glacier"], 0.2, 0.3], 14, ["match", ["get", "class"], ["ice", "glacier"], 0, 0.3]], "fill-antialias": false}, "filter": ["any", ["match", ["get", "subclass"], ["allotments", "forest", "glacier", "golf_course", "park", "plant_nursery", "recreation_ground", "scrub", "woody_plant", "loose_forest"], true, false], ["case", ["==", ["typeof", ["get", "class"]], "string"], ["match", ["get", "class"], ["sand"], true, false], false]]}, {"id": "landcover_casing", "type": "line", "source": "base_v1.0.0", "source-layer": "landcover", "minzoom": 10.0, "layout": {"visibility": "visible"}, "paint": {"line-blur": 0.5, "line-color": "rgb(186, 199, 172)", "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 6, 0, 11, 0.25, 16, 0.75], "line-opacity": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 0, 14, 1]}, "filter": ["all", ["in", "subclass", "wood", "scrub", "recreation_ground", "park", "golf_course", "allotments", "plant_nursery", "forest"]]}, {"id": "landuse", "type": "fill", "source": "base_v1.0.0", "source-layer": "landuse", "layout": {"visibility": "visible"}, "paint": {"fill-color": ["match", ["get", "class"], ["pitch"], "rgb(224,234,221)", ["landfill", "quarry"], "rgb(240, 218, 188)", ["cemetery", "zoo"], "rgb(215, 224, 209)", ["parking"], "rgb(255, 255, 255)", "rgb(215, 224, 209)"], "fill-opacity": ["match", ["get", "class"], ["pitch"], 1, 0.3]}, "filter": ["all", ["in", "class", "landfill", "cemetery", "quarry", "zoo", "pitch"]]}, {"id": "landuse_outline", "type": "line", "source": "base_v1.0.0", "source-layer": "landuse", "layout": {"visibility": "visible"}, "paint": {"line-color": ["match", ["get", "class"], ["pitch"], "rgb(130, 130, 130)", "rgba(186, 199, 172, 1)"], "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 11, 0.25, 16, 0.75], "line-opacity": {"stops": [[14, 0], [15, 1]]}}, "filter": ["all", ["in", "class", "cemetery", "zoo", "pitch"]]}, {"id": "water_line_intermittent", "type": "line", "source": "base_v1.0.0", "source-layer": "waterway", "minzoom": 12.0, "layout": {"visibility": "visible"}, "paint": {"line-blur": 0, "line-color": "rgba(48, 48, 48, 1)", "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 13, ["match", ["to-string", ["get", "width"]], ["10", "9", "8"], 3.5, ["7", "6", "5"], 2.5, ["4", "3"], 1.5, ["1", "2"], 1, ["match", ["get", "class"], ["river", "canal"], 3.5, 1]], 16, ["match", ["to-string", ["get", "width"]], "10", 5, "9", 4.5, "8", 4, "7", 3.5, ["6", "5"], 3, "4", 2.5, "3", 2, "2", 1.5, "1", 1, ["match", ["get", "class"], ["river", "canal"], 5, 1.5]], 20, ["match", ["to-string", ["get", "width"]], "10", 10, "9", 9.6, "8", 8.9, "7", 8.2, "6", 7.5, "5", 6.8, "4", 6.1, "3", 5.4, "2", 4.7, "1", 4, ["match", ["get", "class"], ["river", "canal"], 10, 4]]], "line-offset": 0, "line-opacity": {"stops": [[13, 0.25], [16, 0.35]]}, "line-dasharray": {"stops": [[13, [0.5, 3]], [14, [0.75, 4.5]], [15, [1, 6]], [16, [1.25, 7.5]]]}}, "filter": ["all", ["==", "intermittent", 1]]}, {"id": "water_line", "type": "line", "source": "base_v1.0.0", "source-layer": "waterway", "minzoom": 7.0, "layout": {"visibility": "visible"}, "paint": {"line-blur": 0, "line-color": ["interpolate", ["linear"], ["zoom"], 10, "rgba(138, 195, 229, 1)", 16, ["match", ["get", "class"], ["river", "canal"], "rgba(160, 205, 236, 1)", "rgba(140, 185, 226, 1)"]], "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 7, 1, 10, ["match", ["to-string", ["get", "width"]], ["10", "9", "8", "7", "6"], 1.5, ["5", "4", "3", "2", "1"], 1, ["match", ["get", "class"], ["river", "canal"], 1.5, 1]], 13, ["match", ["to-string", ["get", "width"]], ["10", "9", "8"], 3.5, ["7", "6", "5"], 2.5, ["4", "3"], 1.5, ["1", "2"], 1, ["match", ["get", "class"], ["river", "canal"], 3.5, 1]], 16, ["match", ["to-string", ["get", "width"]], "10", 5, "9", 4.5, "8", 4, "7", 3.5, ["6", "5"], 3, "4", 2.5, "3", 2, "2", 1.5, "1", 1, ["match", ["get", "class"], ["river", "canal"], 5, 1.5]], 20, ["match", ["to-string", ["get", "width"]], "10", 10, "9", 9.6, "8", 8.9, "7", 8.2, "6", 7.5, "5", 6.8, "4", 6.1, "3", 5.4, "2", 4.7, "1", 4, ["match", ["get", "class"], ["river", "canal"], 10, 4]]], "line-offset": 0, "line-opacity": 0.7}, "filter": ["all", ["!=", "intermittent", 1], ["!in", "class", "riverbank", "shoreline", "shoreline_changing_level"]]}, {"id": "water", "type": "fill", "source": "base_v1.0.0", "source-layer": "water", "layout": {"visibility": "visible"}, "paint": {"fill-color": ["interpolate", ["exponential", 1.5], ["zoom"], 4, "rgb(209, 228, 240)", 10, "rgb(199, 224, 245)"]}}, {"id": "hachure", "type": "fill", "source": "relief_v1.0.0", "source-layer": "hachure", "layout": {"visibility": "visible"}, "paint": {"fill-color": ["match", ["get", "class"], 1, "rgba(32, 32, 32, 1)", "hsl(203, 60%, 46%)"], "fill-opacity": ["interpolate", ["linear"], ["zoom"], 9, 0, 11, 0.2, 14, 0.25], "fill-antialias": false}}, {"id": "contour_line_blue", "type": "line", "source": "base_v1.0.0", "source-layer": "contour_line", "minzoom": 13.0, "layout": {"visibility": "visible"}, "paint": {"line-blur": 0.4, "line-color": "rgb(0,136,208)", "line-width": ["interpolate", ["exponential", 1], ["zoom"], 12, ["case", ["has", "lake_depth"], ["match", ["%", ["to-number", ["get", "lake_depth"]], 100], 0, 0.75, 0], ["match", ["%", ["to-number", ["get", "ele"]], 100], 0, 0.75, 0]], 14, ["case", ["has", "lake_depth"], ["match", ["%", ["to-number", ["get", "lake_depth"]], 100], 0, 1, 0], ["match", ["%", ["to-number", ["get", "ele"]], 100], 0, 1, 0]], 15, ["case", ["has", "lake_depth"], ["match", ["%", ["to-number", ["get", "lake_depth"]], 100], 0, 1.5, [20, 40, 60, 80], 0.75, 0], ["match", ["%", ["to-number", ["get", "ele"]], 100], 0, 1.5, [20, 40, 60, 80], 0.75, 0]], 18, ["case", ["has", "lake_depth"], ["match", ["%", ["to-number", ["get", "lake_depth"]], 100], 0, 2, [20, 40, 60, 80], 1.5, 1], ["match", ["%", ["to-number", ["get", "ele"]], 100], 0, 2, [20, 40, 60, 80], 1.5, 1]]], "line-opacity": ["interpolate", ["linear"], ["zoom"], 13, ["match", ["get", "class"], ["scree", "ice", "water"], 0.25, 0.45], 14, ["match", ["get", "class"], ["scree", "ice", "water"], 0.3, 0.55]]}, "metadata": {"maputnik:comment": "color: rgb(191, 138, 64)"}, "filter": ["in", "class", "ice", "water"]}, {"id": "water_outline", "type": "line", "source": "base_v1.0.0", "source-layer": "water", "layout": {"visibility": "visible"}, "paint": {"line-blur": 0.5, "line-color": "rgba(133, 189, 224, 1)", "line-width": 0.5, "line-opacity": ["interpolate", ["exponential", 1.5], ["zoom"], 0, 0, 8, 1, 14, 0]}, "filter": ["all", ["!=", "class", "ocean"]]}, {"id": "pattern_landcover", "type": "fill", "source": "base_v1.0.0", "source-layer": "landcover", "minzoom": 13.0, "layout": {"visibility": "visible"}, "paint": {"fill-opacity": ["interpolate", ["exponential", 2], ["zoom"], 13, 0, 13.2, ["match", ["get", "class"], "orchard", 0.8, "vineyard", 0.6, 0.4], 17, 1], "fill-pattern": ["match", ["get", "subclass"], "orchard", "orchard_green", "swamp", "wetland_blue", "vineyard", "vinyard_green", ""], "fill-antialias": true}, "filter": ["in", "subclass", "orchard", "swamp", "vineyard"]}, {"id": "park", "type": "line", "source": "base_v1.0.0", "source-layer": "park", "minzoom": 7.0, "layout": {"visibility": "visible"}, "paint": {"line-color": "rgba(171, 197, 159, 1)", "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 9, 1.5, 15, 8, 18, 12], "line-opacity": ["interpolate", ["linear"], ["zoom"], 7, 0, 8, 1, 13, 1, 14, 0.6]}, "filter": ["all", ["in", "class", "national_park"]]}, {"id": "boundary_disputed", "type": "line", "source": "base_v1.0.0", "source-layer": "boundary", "layout": {"line-cap": "square", "line-join": "miter", "visibility": "visible"}, "paint": {"line-blur": 1, "line-color": {"stops": [[6, "hsl(300, 55%, 80%)"], [9, "hsl(315, 40%, 85%)"]]}, "line-width": ["interpolate", ["linear"], ["zoom"], 2, ["match", ["get", "admin_level"], 2, 1, 0], 3, ["match", ["get", "admin_level"], 2, 2, 1], 7, ["match", ["get", "admin_level"], 2, 4, 1.5], 18, ["match", ["get", "admin_level"], 2, 16, 10]], "line-opacity": {"stops": [[13, 1], [14, 0.7]]}, "line-dasharray": [1.5, 2.5]}, "filter": ["all", ["in", "admin_level", 2, 4], ["!=", "maritime", 1], ["==", "disputed", 1], ["==", "$type", "LineString"]]}, {"id": "boundary", "type": "line", "source": "base_v1.0.0", "source-layer": "boundary", "layout": {"line-cap": ["literal", "round"], "line-join": ["literal", "miter"], "visibility": "visible"}, "paint": {"line-blur": 1, "line-color": {"stops": [[6, "hsl(300, 55%, 80%)"], [9, "hsl(315, 40%, 85%)"]]}, "line-width": ["interpolate", ["linear"], ["zoom"], 2, ["match", ["get", "admin_level"], 2, 1, 0], 3, ["match", ["get", "admin_level"], 2, 2, 1], 7, ["match", ["get", "admin_level"], 2, 3, 1], 18, ["match", ["get", "admin_level"], 2, 16, 10]], "line-opacity": {"stops": [[13, 1], [14, 0.8]]}}, "filter": ["all", ["in", "admin_level", 2, 4], ["!=", "maritime", 1], ["!=", "disputed", 1], ["==", "$type", "LineString"]]}, {"id": "tunnel_public_transport", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 8.0, "layout": {"line-cap": "butt", "line-join": "miter", "visibility": "visible"}, "paint": {"line-blur": ["interpolate", ["linear"], ["zoom"], 8, ["match", ["get", "class"], ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail"], ["match", ["get", "service"], ["yard", "siding"], 2.5, 0.6], 0.6], 0.6], 14, ["match", ["get", "class"], ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail"], ["match", ["get", "service"], ["yard", "siding"], 2.5, 0.6], 0.6], 0.6], 18, 0.4], "line-color": {"stops": [[7, "rgba(255, 50, 50, 1)"], [15, "rgba(255, 80, 80, 1)"]]}, "line-width": ["interpolate", ["linear"], ["zoom"], 9, ["match", ["get", "subclass"], "rail", 0.75, 0], 10, ["match", ["get", "class"], ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail"], ["match", ["get", "service"], ["yard", "siding"], 0, 0.75], 0.5], ["match", ["get", "subclass"], ["cable_car", "funicular"], 0.75, 0.5]], 14, ["match", ["get", "class"], "transit", 1, ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail"], ["match", ["get", "service"], ["yard", "siding"], 0, 2], 1], ["match", ["get", "subclass"], ["cable_car", "funicular"], 1, 0]], 18, ["match", ["get", "class"], ["transit"], 1.5, ["match", ["get", "service"], ["yard", "siding"], 1.5, 2]]], "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, ["match", ["get", "is_route"], 99, ["match", ["get", "class"], ["rail"], 0.8, 0], 0], 13, ["match", ["get", "subclass"], ["rail", "subway", "funicular", "rack_rail", "narrow_gauge"], 0.8, 0], 14, ["match", ["get", "subclass"], ["rail", "narrow_gauge", "funicular", "rack_rail", "subway"], ["match", ["get", "service"], ["yard", "siding"], 0, 0.8], 0], 14.5, 0.8], "line-dasharray": {"stops": [[13, [4, 2.5]], [14, [6, 3.75]], [15, [10, 6.25]], [16, [14, 8.75]]]}}, "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "rail", "transit", "cable_car", "gondola"], ["==", "$type", "LineString"]]}, {"id": "tunnel_road", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 7.0, "layout": {"line-cap": "butt", "line-join": "miter", "visibility": "visible"}, "paint": {"line-blur": ["interpolate", ["linear"], ["zoom"], 7, 3, 8, 0.4], "line-color": {"stops": [[6, "rgba(170, 170, 170, 1)"], [11, "rgba(130, 130, 130, 1)"]]}, "line-width": ["interpolate", ["linear"], ["zoom"], 8, ["match", ["get", "is_route"], [5, 10, 99], 1, ["match", ["get", "ramp"], 1, 1, 0]], 10, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway", "trunk"], 1.2, ["primary", "secondary", "tertiary", "minor"], 1, 0], 0], 11, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway", "trunk"], 1.3, ["primary", "secondary", "tertiary", "minor"], 1, 0], 0.25], 13, ["match", ["get", "class"], ["motorway", "trunk"], 1.5, ["primary", "secondary", "tertiary", "minor"], 1, 0], 14, ["match", ["get", "class"], ["motorway", "trunk"], 2, ["primary", "secondary", "tertiary", "minor"], 1.5, 0], 15, ["match", ["get", "class"], ["motorway", "trunk"], 3, 2], 20, ["match", ["get", "class"], ["motorway", "trunk"], 5, 2.5]], "line-offset": 0, "line-opacity": ["step", ["zoom"], ["match", ["get", "oneway"], 2, 0, 1], 12, 1], "line-dasharray": {"stops": [[13, [3, 1.875]], [14, [4, 2.5]], [15, [5, 3.125]], [16, [6, 3.75]]]}}, "filter": ["all", ["==", "brunnel", "tunnel"], ["!in", "class", "rail", "ferry", "car_ferry", "transit", "cable_car", "chair_lift", "gondola", "drag_lift", "aerialway_transportation", "aerialway_closed", "aerialway_goods", "goods_conveyor", "path", "footway", "trail", "rail", "transit", "via_ferrata", "mask_terrain"], ["==", "$type", "LineString"]]}, {"id": "tunnel_pedestrian", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 6.0, "layout": {"line-cap": "butt", "line-join": "miter", "visibility": "visible"}, "paint": {"line-blur": 0.4, "line-color": "rgba(140, 140, 140, 1)", "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 15, 1.1, 20, 3], "line-offset": 0, "line-opacity": {"stops": [[14, 0], [15, 1]]}, "line-dasharray": {"stops": [[14, [1, 0.6]], [15, [1.5, 0.9]], [16, [2, 1.2]], [17, [3, 1.8]], [18, [6, 3.6]]]}}, "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "path", "footway", "trail"], ["==", "$type", "LineString"]]}, {"id": "construct_line", "type": "line", "source": "base_v1.0.0", "source-layer": "construct", "minzoom": 14.0, "layout": {"visibility": "visible"}, "paint": {"line-blur": 0.4, "line-color": "hsl(220, 10%, 67%)", "line-width": {"stops": [[15, 1], [18, 4]]}, "line-opacity": {"stops": [[14, 0], [15, 1]]}}, "filter": ["all", ["==", "class", "lock"]]}, {"id": "construct", "type": "fill", "source": "base_v1.0.0", "source-layer": "construct", "minzoom": 13.0, "layout": {"visibility": "visible"}, "paint": {"fill-color": ["interpolate", ["exponential", 1], ["zoom"], 13, ["match", ["get", "class"], "weir", "hsl(220, 10%, 82%)", "hsl(220, 10%, 86%)"], 17, ["match", ["get", "class"], "weir", "hsl(220, 10%, 75%)", "dam", "hsl(220, 10%, 82%)", "hsl(220, 10%, 86%)"]], "fill-opacity": ["interpolate", ["exponential", 1], ["zoom"], 12, 0, 13, ["match", ["get", "class"], "dam", 1, 0], 14, ["match", ["get", "class"], "dam", 1, 0], 15, 1]}, "filter": ["all", ["!=", "class", "lock"]]}, {"id": "aeroway_polygon_casing", "type": "line", "source": "base_v1.0.0", "source-layer": "aeroway", "minzoom": 11.0, "layout": {"line-cap": "round", "line-join": "round", "visibility": "visible"}, "paint": {"line-blur": 0.4, "line-color": "rgb(190, 190, 190)", "line-width": {"base": 1.5, "stops": [[13, 3], [15, 4], [17, 5]]}, "line-opacity": 1}, "filter": ["all", ["in", "class", "runway", "taxiway", "apron", "runway_grass"], ["==", "$type", "Polygon"]]}, {"id": "road_via_ferrata_trail", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 14.0, "layout": {"line-cap": "butt", "line-join": "bevel", "visibility": "visible"}, "paint": {"line-blur": {"stops": [[15, 0.1], [20, 0.4]]}, "line-color": {"stops": [[15, "rgba(20, 20, 20, 1)"], [18, "rgba(90, 90, 90, 1)"]]}, "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 15, 1.1, 20, 3], "line-opacity": {"stops": [[14, 0], [15, 1]]}, "line-dasharray": {"stops": [[15, [0.75, 2]], [16, [1.125, 3]], [17, [1.5, 4]], [18, [1.875, 5]]]}}, "metadata": {"maputnik:comment": "cannot be in the same layer as road_casings because dasharray doesn't support expressions"}, "filter": ["all", ["in", "class", "via_ferrata", "trail"], ["!in", "brunnel", "bridge", "tunnel"], ["==", "$type", "LineString"]]}, {"id": "building_ln", "type": "line", "source": "base_v1.0.0", "source-layer": "building_ln", "minzoom": 13.0, "layout": {"visibility": "visible"}, "paint": {"line-blur": 0.4, "line-color": ["match", ["get", "class"], "weir", "rgb(20, 136, 205)", "rgb(130, 130, 130)"], "line-width": {"stops": [[13, 0], [16, 2], [18, 3]]}, "line-opacity": ["match", ["get", "class"], "weir", 0.2, 1]}, "filter": ["all", ["match", ["get", "class"], ["horse_racing", "ski_jump", "toboggan", "track", "weir"], true, false], ["==", ["geometry-type"], "LineString"]]}, {"id": "road_path_footway_ferry", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 11.0, "layout": {"line-cap": "butt", "line-join": "bevel", "visibility": "visible"}, "paint": {"line-blur": ["interpolate", ["linear"], ["zoom"], 15, 0.1, 20, 0.4], "line-color": ["match", ["get", "class"], ["ferry", "car_ferry"], "rgba(105, 187, 218, 1)", "rgba(115, 115, 115, 1)"], "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 13, 0.5, 15, 1.1, 20, 3], "line-opacity": ["interpolate", ["linear"], ["zoom"], 11, ["match", ["get", "is_route"], 11, 0.5, 0], 12, ["match", ["get", "is_route"], 11, ["match", ["get", "class"], ["path", "track", "footway"], 0.5, 0], 0], 14, 1], "line-dasharray": ["step", ["zoom"], ["literal", [6, 2]], 16, ["literal", [9, 3]], 17, ["literal", [12, 4]], 18, ["literal", [15, 5]]]}, "metadata": {"maputnik:comment": "cannot be in the same layer as road_casings because dasharray doesn't support expressions"}, "filter": ["all", ["match", ["get", "class"], ["footway", "footway_construction", "path", "path_construction", "ferry", "car_ferry"], true, false], ["match", ["get", "brunnel"], ["bridge", "tunnel"], ["match", ["get", "subclass"], "covered_bridge", true, false], true]]}, {"id": "road_casing", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 7.0, "layout": {"line-cap": "round", "line-join": "round", "visibility": "visible"}, "paint": {"line-blur": ["interpolate", ["linear"], ["zoom"], 7, 3, 8, 0.4, 11, ["match", ["get", "is_route"], [11], 4, 0.4], 13, ["match", ["get", "class"], "track", 2, 0.4], 14, 0.4], "line-color": ["interpolate", ["exponential", 1.7], ["zoom"], 9, ["match", ["get", "class"], ["motorway", "trunk"], "rgb(170, 136, 30)", "rgb(80, 80, 80)"], 15, ["match", ["get", "class"], ["motorway", "trunk"], "rgb(139, 107, 63)", "rgb(90, 90, 90)"]], "line-width": ["interpolate", ["exponential", 2], ["zoom"], 7, 0, 8, ["match", ["get", "is_route"], [5, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 1.5, 3], ["trunk"], 3, 3], 0], 9, ["match", ["get", "is_route"], [5, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 2, 3.5], ["trunk"], 3.5, 3], 0], 10, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 2, 4], ["trunk"], 4, "primary", 3.5, "secondary", 3.5, "tertiary", 3, ["minor", "service"], 2.5, 0], ["match", ["get", "ramp"], 1, 2, 0]], 11, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 2.5, 4.5], ["trunk"], 4.5, "primary", 4, "secondary", 4, "tertiary", 3.5, ["minor", "service"], 2.75, ["track", "path", "footway"], 0.5, 0], ["match", ["get", "ramp"], 1, 2.5, 0]], 12, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 2.75, 5], "primary", 5, "secondary", 4.5, "tertiary", 4, ["minor", "service"], 3, ["track", "path", "footway"], 0.75, 0], [11], ["match", ["get", "class"], ["motorway", "trunk", "primary", "secondary", "tertiary", "minor"], ["match", ["get", "ramp"], 1, 2.75, 0.75], ["track", "path", "footway"], 0.5, 0], ["match", ["get", "ramp"], 1, 2.75, 0]], 13, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 3, 6], ["trunk"], 6, "primary", 5.5, "secondary", 5, "tertiary", 4.5, ["minor", "service"], 3.5, ["track", "path", "footway"], ["match", ["get", "subclass"], "covered_bridge", 3, 1], 3], 11, ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 3, 6], "primary", 4, "secondary", 3.75, "tertiary", 3.75, ["minor", "service"], 3, ["track", "path", "footway"], ["match", ["get", "subclass"], "covered_bridge", 3, 0.5], 3], 0], 14, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 4, 7], ["trunk"], 7, "primary", 6.5, "secondary", 6, "tertiary", 5, ["minor", "service"], 4, ["track", "path", "footway"], ["match", ["get", "subclass"], "covered_bridge", 3.5, 0.75], 3.5], ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 4, 7], "primary", 4.5, "secondary", 4, "tertiary", 4, ["minor", "service"], 3.5, ["track", "path", "footway"], ["match", ["get", "subclass"], "covered_bridge", 3.5, 0.75], 3.5]], 15, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 6.5, 8], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 8, 12], ["primary", "primary_construction"], 8, ["secondary", "secondary_construction"], 7, ["tertiary", "tertiary_construction"], 6, ["minor", "minor_construction", "service", "service_construction", "rail", "rail_construction"], 5, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], ["match", ["get", "subclass"], "covered_bridge", 4, 1.1], 4.5], 16, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 10, 12], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 12, 15], ["primary", "primary_construction"], 12, ["secondary", "secondary_construction"], 11, ["tertiary", "tertiary_construction"], 10, ["minor", "minor_construction", "service", "service_construction", "rail", "rail_construction"], 9, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], ["match", ["get", "subclass"], "covered_bridge", 8.5, 1.1], 8], 20, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 115, 130], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 130, 140], ["primary", "primary_construction"], 120, ["secondary", "secondary_construction"], 115, ["tertiary", "tertiary_construction"], 110, ["minor", "minor_construction", "service", "service_construction", "rail", "rail_construction"], 100, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], ["match", ["get", "subclass"], "covered_bridge", 84, 5], 100]], "line-offset": 0, "line-opacity": 1}, "metadata": {"maputnik:comment": "casing color for motorway is brown"}, "filter": ["all", ["match", ["get", "class"], ["cable_car", "car_ferry", "chair_lift", "ferry", "gondola", "drag_lift", "aerialway_transportation", "aerialway_closed", "aerialway_goods", "goods_conveyor", "path", "footway", "trail", "rail", "transit", "via_ferrata", "mask_terrain"], false, true], ["match", ["get", "brunnel"], ["bridge", "tunnel"], ["match", ["get", "subclass"], "covered_bridge", true, false], true], ["==", ["geometry-type"], "LineString"]]}, {"id": "landuse_parking", "type": "fill", "source": "base_v1.0.0", "source-layer": "landuse", "layout": {"visibility": "visible"}, "paint": {"fill-color": "rgba(255, 255, 255, 1)", "fill-opacity": {"stops": [[14, 0], [15, 1]]}}, "filter": ["all", ["in", "class", "parking"]]}, {"id": "landuse_parking_outline", "type": "line", "source": "base_v1.0.0", "source-layer": "landuse", "layout": {"visibility": "visible"}, "paint": {"line-color": "rgba(130, 130, 130, 1)", "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 11, 0.25, 16, 0.75], "line-opacity": {"stops": [[14, 0], [15, 1]]}}, "filter": ["all", ["in", "class", "parking"]]}, {"id": "road_fill", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 7.0, "layout": {"line-cap": "round", "line-join": "round", "visibility": "visible"}, "paint": {"line-blur": {"stops": [[8, 0.4], [14, 0.1]]}, "line-color": ["interpolate", ["exponential", 1], ["zoom"], 9, ["match", ["get", "class"], ["motorway", "trunk"], "rgb(255,230,160)", "rgb(255,255,255)"], 15, ["match", ["get", "class"], ["motorway", "trunk"], "rgb(255, 224, 138)", "rgb(255,255,255)"]], "line-width": ["interpolate", ["exponential", 2], ["zoom"], 7, 0, 8, ["match", ["get", "is_route"], [5, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 0.5, 2], ["trunk"], 2, 2], ["match", ["get", "ramp"], 1, 0.5, 0]], 9, ["match", ["get", "is_route"], [5, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 0.75, 2.25], ["trunk"], 2.25, 2], ["match", ["get", "ramp"], 1, 0.75, 0]], 10, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 0.75, 2.75], ["trunk"], 2.75, "primary", 2.5, "secondary", 2.5, "tertiary", 2, ["minor", "service"], 1.5, 0], ["match", ["get", "ramp"], 1, 0.75, 0]], 11, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 1.25, 3.25], ["trunk"], 3.25, "primary", 3, "secondary", 3, "tertiary", 2.5, ["minor", "service"], 1.75, 0], ["match", ["get", "ramp"], 1, 1.25, 0]], 12, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 1.5, 3.75], ["trunk"], 3.75, "primary", 4, "secondary", 3.5, "tertiary", 3, ["minor", "service"], 2, 0], ["match", ["get", "ramp"], 1, 1.5, 0]], 13, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 1.75, 4.5], ["trunk"], 4.5, "primary", 4.5, "secondary", 4, "tertiary", 3.5, ["minor", "service"], 2.5, 2], 11, ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 1.75, 4.5], "primary", 3, "secondary", 2.75, "tertiary", 2.75, ["minor", "service"], 2, 2], ["match", ["get", "ramp"], 1, 1.75, 0]], 14, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 2.75, 5.5], ["trunk"], 5.5, "primary", 5.5, "secondary", 5, "tertiary", 4, ["minor", "service"], 3, 2.5], ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 2.75, 5.5], "primary", 3.5, "secondary", 3, "tertiary", 3, ["minor", "service"], 2.5, 2.5]], 15, ["match", ["get", "subclass"], "funicular", 3, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 5, 6.5], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 6.5, 10.5], ["primary", "primary_construction"], 6.5, ["secondary", "secondary_construction"], 5.5, ["tertiary", "tertiary_construction"], 4.5, ["minor", "minor_construction", "service", "service_construction"], 3.5, ["rail", "rail_construction"], 5, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], 1.25, 3]], 16, ["match", ["get", "subclass"], "funicular", 6, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 8, 10], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 10, 13], ["primary", "primary_construction"], 10, ["secondary", "secondary_construction"], 9, ["tertiary", "tertiary_construction"], 8, ["minor", "minor_construction", "service", "service_construction"], 7, ["rail", "rail_construction"], 9.5, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], 6.5, 6]], 20, ["match", ["get", "subclass"], "funicular", 20, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 112, 126], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 126, 136], ["primary", "primary_construction"], 117, ["secondary", "secondary_construction"], 112, ["tertiary", "tertiary_construction"], 107, ["minor", "minor_construction", "service", "service_construction"], 97, ["rail", "rail_construction"], 120, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], 81, 92]]]}, "filter": ["all", ["match", ["get", "class"], ["cable_car", "car_ferry", "chair_lift", "ferry", "gondola", "drag_lift", "aerialway_transportation", "aerialway_closed", "aerialway_goods", "goods_conveyor", "path", "footway", "trail", "rail", "track", "transit", "via_ferrata", "mask_terrain"], false, true], ["match", ["get", "brunnel"], ["bridge", "tunnel"], ["match", ["get", "subclass"], "covered_bridge", true, false], true], ["==", ["geometry-type"], "LineString"]]}, {"id": "aeroway_polygon_fill", "type": "fill", "source": "base_v1.0.0", "source-layer": "aeroway", "minzoom": 11.0, "layout": {"visibility": "visible"}, "paint": {"fill-color": ["match", ["get", "class"], ["runway_grass"], "rgb(224,234,221)", "rgb(255, 255, 255)"], "fill-opacity": 1, "fill-antialias": true}, "filter": ["all", ["in", "class", "runway", "taxiway", "apron", "runway_grass"], ["==", "$type", "Polygon"]]}, {"id": "public_transport", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 8.0, "layout": {"visibility": "visible"}, "paint": {"line-blur": ["interpolate", ["linear"], ["zoom"], 8, ["match", ["get", "class"], ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail"], ["match", ["get", "service"], ["yard", "siding"], 2.5, 0.6], 0.6], 0.6], 14, ["match", ["get", "class"], ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail"], ["match", ["get", "service"], ["yard", "siding"], 2.5, 0.6], 0.6], 0.6], 18, 0.4], "line-color": {"stops": [[7, "rgba(255, 50, 50, 1)"], [15, "rgba(255, 80, 80, 1)"]]}, "line-width": ["interpolate", ["linear"], ["zoom"], 9, ["match", ["get", "is_route"], 99, ["match", ["get", "subclass"], "rail", 0.75, 0], 0], 10, ["match", ["get", "is_route"], 99, ["match", ["get", "class"], ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail"], ["match", ["get", "service"], ["yard", "siding"], 0, 0.75], 0.5], 0.5], ["match", ["get", "subclass"], ["cable_car", "funicular"], 0.75, 0]], 14, ["match", ["get", "is_route"], 99, ["match", ["get", "class"], "transit", 1, ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail"], 2, 1], 0], ["match", ["get", "subclass"], ["cable_car", "funicular"], 1, 0]], 18, ["match", ["get", "class"], ["transit"], 1.5, ["match", ["get", "service"], ["yard", "siding"], 1.5, 2]]], "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, ["match", ["get", "class"], ["rail"], 1, 0], 13, ["match", ["get", "class"], ["rail"], 1, 0], 14, ["match", ["get", "class"], ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail", "funicular"], ["match", ["get", "service"], ["yard", "siding"], 0, 1], 0], 0], 14.5, 1]}, "filter": ["all", ["in", "class", "rail", "transit"], ["!in", "brunnel", "bridge", "tunnel"]]}, {"id": "building", "type": "fill", "source": "base_v1.0.0", "source-layer": "building", "minzoom": 13.0, "layout": {"visibility": "visible"}, "paint": {"fill-color": {"stops": [[13, "hsl(220, 10%, 82%)"], [17, "hsl(220, 10%, 75%)"]]}, "fill-opacity": {"stops": [[13, 0], [13.5, 1]]}}}, {"id": "building_casing", "type": "line", "source": "base_v1.0.0", "source-layer": "building", "minzoom": 15.0, "layout": {"visibility": "visible"}, "paint": {"line-color": "hsl(220, 10%, 67%)", "line-width": ["interpolate", ["linear"], ["zoom"], 15, 0.2, 18, 1.2], "line-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 16, 0.5, 19, 1]}}, {"id": "l1_road_casing", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 7.0, "layout": {"line-cap": "butt", "line-join": "round", "visibility": "visible"}, "paint": {"line-blur": ["interpolate", ["linear"], ["zoom"], 7, 3, 8, 0.4, 11, ["match", ["get", "is_route"], [11], 4, 0.4], 13, ["match", ["get", "class"], "track", 2, 0.4], 14, 0.4], "line-color": ["interpolate", ["exponential", 1.7], ["zoom"], 9, ["match", ["get", "class"], ["motorway", "trunk"], "rgb(170, 136, 30)", "rgb(80, 80, 80)"], 15, ["match", ["get", "class"], ["motorway", "trunk"], "rgb(139, 107, 63)", "rgb(90, 90, 90)"]], "line-width": ["interpolate", ["exponential", 2], ["zoom"], 7, 0, 8, ["match", ["get", "is_route"], [5, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 1.5, 3], ["trunk"], 3, 3], 0], 9, ["match", ["get", "is_route"], [5, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 2, 3.5], ["trunk"], 3.5, 3], 0], 10, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 2, 4], ["trunk"], 4, "primary", 3.5, "secondary", 3.5, "tertiary", 3, ["minor", "service"], 2.5, 0], ["match", ["get", "ramp"], 1, 2, 0]], 11, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 2.5, 4.5], ["trunk"], 4.5, "primary", 4, "secondary", 4, "tertiary", 3.5, ["minor", "service"], 2.75, ["track", "path", "footway"], 0.5, 0], ["match", ["get", "ramp"], 1, 2.5, 0]], 12, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 2.75, 5], "primary", 5, "secondary", 4.5, "tertiary", 4, ["minor", "service"], 3, ["track", "path", "footway"], 0.75, 0], [11], ["match", ["get", "class"], ["motorway", "trunk", "primary", "secondary", "tertiary", "minor"], ["match", ["get", "ramp"], 1, 2.75, 0.75], ["track", "path", "footway"], 0.5, 0], ["match", ["get", "ramp"], 1, 2.75, 0]], 13, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 3, 6], ["trunk"], 6, "primary", 5.5, "secondary", 5, "tertiary", 4.5, ["minor", "service"], 3.5, ["track", "path", "footway"], ["match", ["get", "subclass"], "covered_bridge", 3, 1], 3], 11, ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 3, 6], "primary", 4, "secondary", 3.75, "tertiary", 3.75, ["minor", "service"], 3, ["track", "path", "footway"], ["match", ["get", "subclass"], "covered_bridge", 3, 0.5], 3], 0], 14, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 4, 7], ["trunk"], 7, "primary", 6.5, "secondary", 6, "tertiary", 5, ["minor", "service"], 4, ["track", "path", "footway"], ["match", ["get", "subclass"], "covered_bridge", 3.5, 0.75], 3.5], ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 4, 7], "primary", 4.5, "secondary", 4, "tertiary", 4, ["minor", "service"], 3.5, ["track", "path", "footway"], ["match", ["get", "subclass"], "covered_bridge", 3.5, 0.75], 3.5]], 15, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 6.5, 8], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 8, 12], ["primary", "primary_construction"], 8, ["secondary", "secondary_construction"], 7, ["tertiary", "tertiary_construction"], 6, ["minor", "minor_construction", "service", "service_construction", "rail", "rail_construction"], 5, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], ["match", ["get", "subclass"], "covered_bridge", 4, 1.1], 4.5], 16, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 10, 12], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 12, 15], ["primary", "primary_construction"], 12, ["secondary", "secondary_construction"], 11, ["tertiary", "tertiary_construction"], 10, ["minor", "minor_construction", "service", "service_construction", "rail", "rail_construction"], 9, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], ["match", ["get", "subclass"], "covered_bridge", 8.5, 1.1], 8], 20, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 115, 130], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 130, 140], ["primary", "primary_construction"], 120, ["secondary", "secondary_construction"], 115, ["tertiary", "tertiary_construction"], 110, ["minor", "minor_construction", "service", "service_construction", "rail", "rail_construction"], 100, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], ["match", ["get", "subclass"], "covered_bridge", 84, 5], 100]], "line-offset": 0, "line-opacity": 1}, "metadata": {"maputnik:comment": "casing color for motorway is brown"}, "filter": ["all", ["has", "layer"], ["==", "layer", 1], ["!in", "class", "rail", "ferry", "path", "track", "footway", "transit", "cable_car", "gondola", "drag_lift", "aerialway_transportation", "aerialway_closed", "aerialway_goods", "goods_conveyor", "mask_terrain", "via_ferrata"], ["==", "$type", "LineString"], ["!=", "subclass", "covered_bridge"]]}, {"id": "l1_fill", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 7.0, "layout": {"line-cap": "butt", "line-join": "round", "visibility": "visible"}, "paint": {"line-blur": {"stops": [[8, 0.4], [14, 0.1]]}, "line-color": ["interpolate", ["exponential", 1], ["zoom"], 9, ["match", ["get", "class"], ["motorway", "trunk"], "rgb(255,230,160)", ["rail", "path", "track", "footway"], ["match", ["get", "subclass"], "funicular", "rgba(243,243,246,0)", "rgb(243,243,246)"], "rgb(255,255,255)", "rgb(243,243,246)", "rgb(255,255,255)"], 15, ["match", ["get", "class"], ["motorway", "trunk"], "rgb(255, 224, 138)", ["rail", "path", "track", "footway"], ["match", ["get", "subclass"], "funicular", "rgba(243,243,246,0)", "rgb(243,243,246)"], "rgb(255,255,255)"]], "line-width": ["interpolate", ["exponential", 2], ["zoom"], 7, 0, 8, ["match", ["get", "is_route"], [5, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 0.5, 2], ["trunk"], 2, 2], ["match", ["get", "ramp"], 1, 0.5, 0]], 9, ["match", ["get", "is_route"], [5, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 0.75, 2.25], ["trunk"], 2.25, 2], ["match", ["get", "ramp"], 1, 0.75, 0]], 10, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 0.75, 2.75], ["trunk"], 2.75, "primary", 2.5, "secondary", 2.5, "tertiary", 2, ["minor", "service"], 1.5, 0], ["match", ["get", "ramp"], 1, 0.75, 0]], 11, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 1.25, 3.25], ["trunk"], 3.25, "primary", 3, "secondary", 3, "tertiary", 2.5, ["minor", "service"], 1.75, 0], ["match", ["get", "ramp"], 1, 1.25, 0]], 12, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 1.5, 3.75], ["trunk"], 3.75, "primary", 4, "secondary", 3.5, "tertiary", 3, ["minor", "service"], 2, 0], ["match", ["get", "ramp"], 1, 1.5, 0]], 13, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 1.75, 4.5], ["trunk"], 4.5, "primary", 4.5, "secondary", 4, "tertiary", 3.5, ["minor", "service"], 2.5, 2], 11, ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 1.75, 4.5], "primary", 3, "secondary", 2.75, "tertiary", 2.75, ["minor", "service"], 2, 2], ["match", ["get", "ramp"], 1, 1.75, 0]], 14, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 2.75, 5.5], ["trunk"], 5.5, "primary", 5.5, "secondary", 5, "tertiary", 4, ["minor", "service"], 3, 1.5], ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 2.75, 5.5], "primary", 3.5, "secondary", 3, "tertiary", 3, ["minor", "service"], 2.5, 2.5]], 15, ["match", ["get", "subclass"], "funicular", 3, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 5, 6.5], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 6.5, 10.5], ["primary", "primary_construction"], 6.5, ["secondary", "secondary_construction"], 5.5, ["tertiary", "tertiary_construction"], 4.5, ["minor", "minor_construction", "service", "service_construction"], 3.5, ["rail", "rail_construction"], 5, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], 1.25, 3]], 16, ["match", ["get", "subclass"], "funicular", 6, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 8, 10], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 10, 13], ["primary", "primary_construction"], 10, ["secondary", "secondary_construction"], 9, ["tertiary", "tertiary_construction"], 8, ["minor", "minor_construction", "service", "service_construction"], 7, ["rail", "rail_construction"], 9.5, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], 6.5, 6]], 20, ["match", ["get", "subclass"], "funicular", 20, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 112, 126], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 126, 136], ["primary", "primary_construction"], 117, ["secondary", "secondary_construction"], 112, ["tertiary", "tertiary_construction"], 107, ["minor", "minor_construction", "service", "service_construction"], 97, ["rail", "rail_construction"], 120, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], 81, 92]]], "line-opacity": 1}, "filter": ["all", ["has", "layer"], ["==", "layer", 1], ["!in", "class", "ferry", "cable_car", "gondola", "drag_lift", "aerialway_transportation", "aerialway_closed", "aerialway_goods", "goods_conveyor", "mask_terrain", "via_ferrata"], ["==", "$type", "LineString"], ["!=", "subclass", "covered_bridge"]]}, {"id": "l1_2_pedestrian", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 11.0, "layout": {"line-cap": "butt", "line-join": "round", "visibility": "visible"}, "paint": {"line-blur": ["interpolate", ["linear"], ["zoom"], 13, 2, 14, 0.4], "line-color": "rgb(90, 90, 90)", "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 11, 0, 13, 0.5, 15, 1.1, 20, 3], "line-opacity": ["match", ["get", "class"], ["track", "footway", "path"], 1, 0.5]}, "metadata": {"maputnik:comment": "casing color for motorway is brown"}, "filter": ["all", ["has", "layer"], [">", "layer", 0], ["in", "class", "track", "track_construction", "footway", "footway_construction", "path", "path_construction"], ["!=", "subclass", "covered_bridge"], ["==", "$type", "LineString"]]}, {"id": "l1_public_transport", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 10.0, "layout": {"line-cap": "round", "visibility": "visible"}, "paint": {"line-blur": ["interpolate", ["linear"], ["zoom"], 8, ["match", ["get", "class"], ["rail"], ["match", ["get", "subclass"], ["rail"], ["match", ["get", "service"], ["yard", "siding"], 2.5, 0.6], 0.6], 0.6], 14, ["match", ["get", "class"], ["rail"], ["match", ["get", "subclass"], ["rail"], ["match", ["get", "service"], ["yard", "siding"], 2.5, 0.6], 0.6], 0.6], 18, 0.4], "line-color": {"stops": [[7, "rgba(255, 50, 50, 1)"], [15, "rgba(255, 80, 80, 1)"]]}, "line-width": ["interpolate", ["linear"], ["zoom"], 9, ["match", ["get", "is_route"], 99, ["match", ["get", "subclass"], "rail", 0.75, 0], 0], 10, ["match", ["get", "is_route"], 99, ["match", ["get", "class"], ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail"], ["match", ["get", "service"], ["yard", "siding"], 0, 0.75], 0.5], 0.5], ["match", ["get", "subclass"], ["cable_car", "funicular"], 0.75, 0]], 14, ["match", ["get", "is_route"], 99, ["match", ["get", "class"], "transit", 1, ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail"], ["match", ["get", "service"], ["yard", "siding"], 0, 2], 1], 0], ["match", ["get", "subclass"], ["cable_car", "funicular"], 1, 0]], 18, ["match", ["get", "class"], ["transit"], 1.5, ["match", ["get", "service"], ["yard", "siding"], 1.5, 2]]], "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, ["match", ["get", "class"], ["rail"], 1, 0], 12, ["match", ["get", "class"], ["rail"], 1, 0], 13, ["match", ["get", "class"], ["rail"], 1, 0], 14, ["match", ["get", "class"], ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail", "funicular"], ["match", ["get", "service"], ["yard", "siding"], 0, 1], 0], 0], 14.5, 1]}, "filter": ["all", ["has", "layer"], ["==", "layer", 1], ["in", "class", "rail", "transit"], ["==", "$type", "LineString"], ["!=", "subclass", "covered_bridge"]]}, {"id": "l2_road_casing", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 7.0, "layout": {"line-cap": "butt", "line-join": "round", "visibility": "visible"}, "paint": {"line-blur": ["interpolate", ["linear"], ["zoom"], 7, 3, 8, 0.4, 11, ["match", ["get", "is_route"], [11], 4, 0.4], 13, ["match", ["get", "class"], "track", 2, 0.4], 14, 0.4], "line-color": ["interpolate", ["exponential", 1.7], ["zoom"], 9, ["match", ["get", "class"], ["motorway", "trunk"], "rgb(170, 136, 30)", "rgb(80, 80, 80)"], 15, ["match", ["get", "class"], ["motorway", "trunk"], "rgb(139, 107, 63)", "rgb(90, 90, 90)"]], "line-width": ["interpolate", ["exponential", 2], ["zoom"], 7, 0, 8, ["match", ["get", "is_route"], [5, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 1.5, 3], ["trunk"], 3, 3], 0], 9, ["match", ["get", "is_route"], [5, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 2, 3.5], ["trunk"], 3.5, 3], 0], 10, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 2, 4], ["trunk"], 4, "primary", 3.5, "secondary", 3.5, "tertiary", 3, ["minor", "service"], 2.5, 0], ["match", ["get", "ramp"], 1, 2, 0]], 11, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 2.5, 4.5], ["trunk"], 4.5, "primary", 4, "secondary", 4, "tertiary", 3.5, ["minor", "service"], 2.75, ["track", "path", "footway"], 0.5, 0], ["match", ["get", "ramp"], 1, 2.5, 0]], 12, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 2.75, 5], "primary", 5, "secondary", 4.5, "tertiary", 4, ["minor", "service"], 3, ["track", "path", "footway"], 0.75, 0], [11], ["match", ["get", "class"], ["motorway", "trunk", "primary", "secondary", "tertiary", "minor"], ["match", ["get", "ramp"], 1, 2.75, 0.75], ["track", "path", "footway"], 0.5, 0], ["match", ["get", "ramp"], 1, 2.75, 0]], 13, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 3, 6], ["trunk"], 6, "primary", 5.5, "secondary", 5, "tertiary", 4.5, ["minor", "service"], 3.5, ["track", "path", "footway"], ["match", ["get", "subclass"], "covered_bridge", 3, 1], 3], 11, ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 3, 6], "primary", 4, "secondary", 3.75, "tertiary", 3.75, ["minor", "service"], 3, ["track", "path", "footway"], ["match", ["get", "subclass"], "covered_bridge", 3, 0.5], 3], 0], 14, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 4, 7], ["trunk"], 7, "primary", 6.5, "secondary", 6, "tertiary", 5, ["minor", "service"], 4, ["track", "path", "footway"], ["match", ["get", "subclass"], "covered_bridge", 3.5, 0.75], 3.5], ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 4, 7], "primary", 4.5, "secondary", 4, "tertiary", 4, ["minor", "service"], 3.5, ["track", "path", "footway"], ["match", ["get", "subclass"], "covered_bridge", 3.5, 0.75], 3.5]], 15, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 6.5, 8], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 8, 12], ["primary", "primary_construction"], 8, ["secondary", "secondary_construction"], 7, ["tertiary", "tertiary_construction"], 6, ["minor", "minor_construction", "service", "service_construction", "rail", "rail_construction"], 5, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], ["match", ["get", "subclass"], "covered_bridge", 4, 1.1], 4.5], 16, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 10, 12], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 12, 15], ["primary", "primary_construction"], 12, ["secondary", "secondary_construction"], 11, ["tertiary", "tertiary_construction"], 10, ["minor", "minor_construction", "service", "service_construction", "rail", "rail_construction"], 9, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], ["match", ["get", "subclass"], "covered_bridge", 8.5, 1.1], 8], 20, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 115, 130], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 130, 140], ["primary", "primary_construction"], 120, ["secondary", "secondary_construction"], 115, ["tertiary", "tertiary_construction"], 110, ["minor", "minor_construction", "service", "service_construction", "rail", "rail_construction"], 100, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], ["match", ["get", "subclass"], "covered_bridge", 84, 5], 100]], "line-offset": 0, "line-opacity": 1}, "metadata": {"maputnik:comment": "casing color for motorway is brown"}, "filter": ["all", ["has", "layer"], [">", "layer", 1], ["!in", "class", "rail", "ferry", "path", "track", "footway", "transit", "cable_car", "gondola", "drag_lift", "aerialway_transportation", "aerialway_closed", "aerialway_goods", "goods_conveyor", "mask_terrain", "via_ferrata"], ["==", "$type", "LineString"], ["!=", "subclass", "covered_bridge"]]}, {"id": "l2_fill", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 7.0, "layout": {"line-cap": "butt", "line-join": "round", "visibility": "visible"}, "paint": {"line-blur": {"stops": [[8, 0.4], [14, 0.1]]}, "line-color": ["interpolate", ["exponential", 1], ["zoom"], 9, ["match", ["get", "class"], ["motorway", "trunk"], "rgb(255,230,160)", ["rail", "path", "track", "footway"], ["match", ["get", "subclass"], "funicular", "rgba(243,243,246,0)", "rgb(243,243,246)"], "rgb(255,255,255)", "rgb(243,243,246)", "rgb(255,255,255)"], 15, ["match", ["get", "class"], ["motorway", "trunk"], "rgb(255, 224, 138)", ["rail", "path", "track", "footway"], ["match", ["get", "subclass"], "funicular", "rgba(243,243,246,0)", "rgb(243,243,246)"], "rgb(255,255,255)"]], "line-width": ["interpolate", ["exponential", 2], ["zoom"], 7, 0, 8, ["match", ["get", "is_route"], [5, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 0.5, 2], ["trunk"], 2, 2], ["match", ["get", "ramp"], 1, 0.5, 0]], 9, ["match", ["get", "is_route"], [5, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 0.75, 2.25], ["trunk"], 2.25, 2], ["match", ["get", "ramp"], 1, 0.75, 0]], 10, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 0.75, 2.75], ["trunk"], 2.75, "primary", 2.5, "secondary", 2.5, "tertiary", 2, ["minor", "service"], 1.5, 0], ["match", ["get", "ramp"], 1, 0.75, 0]], 11, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 1.25, 3.25], ["trunk"], 3.25, "primary", 3, "secondary", 3, "tertiary", 2.5, ["minor", "service"], 1.75, 0], ["match", ["get", "ramp"], 1, 1.25, 0]], 12, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 1.5, 3.75], ["trunk"], 3.75, "primary", 4, "secondary", 3.5, "tertiary", 3, ["minor", "service"], 2, 0], ["match", ["get", "ramp"], 1, 1.5, 0]], 13, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 1.75, 4.5], ["trunk"], 4.5, "primary", 4.5, "secondary", 4, "tertiary", 3.5, ["minor", "service"], 2.5, 2], 11, ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 1.75, 4.5], "primary", 3, "secondary", 2.75, "tertiary", 2.75, ["minor", "service"], 2, 2], ["match", ["get", "ramp"], 1, 1.75, 0]], 14, ["match", ["get", "is_route"], [5, 6, 7, 8, 10, 99], ["match", ["get", "class"], ["motorway"], ["match", ["get", "ramp"], 1, 2.75, 5.5], ["trunk"], 5.5, "primary", 5.5, "secondary", 5, "tertiary", 4, ["minor", "service"], 3, 2.5], ["match", ["get", "class"], ["motorway", "trunk"], ["match", ["get", "ramp"], 1, 2.75, 5.5], "primary", 3.5, "secondary", 3, "tertiary", 3, ["minor", "service"], 2.5, 2.5]], 15, ["match", ["get", "subclass"], "funicular", 3, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 5, 6.5], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 6.5, 10.5], ["primary", "primary_construction"], 6.5, ["secondary", "secondary_construction"], 5.5, ["tertiary", "tertiary_construction"], 4.5, ["minor", "minor_construction", "service", "service_construction"], 3.5, ["rail", "rail_construction"], 5, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], 1.25, 3]], 16, ["match", ["get", "subclass"], "funicular", 6, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 8, 10], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 10, 13], ["primary", "primary_construction"], 10, ["secondary", "secondary_construction"], 9, ["tertiary", "tertiary_construction"], 8, ["minor", "minor_construction", "service", "service_construction"], 7, ["rail", "rail_construction"], 9.5, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], 6.5, 6]], 20, ["match", ["get", "subclass"], "funicular", 20, ["match", ["get", "class"], ["motorway", "motorway_construction"], ["match", ["get", "ramp"], 1, 112, 126], ["trunk", "trunk_construction"], ["match", ["get", "oneway"], 1, 126, 136], ["primary", "primary_construction"], 117, ["secondary", "secondary_construction"], 112, ["tertiary", "tertiary_construction"], 107, ["minor", "minor_construction", "service", "service_construction"], 97, ["rail", "rail_construction"], 120, ["track", "track_construction", "path", "path_construction", "footway", "footway_construction", "transit", "transit_construction"], 81, 92]]], "line-opacity": 1}, "filter": ["all", ["has", "layer"], [">", "layer", 1], ["!in", "class", "ferry", "cable_car", "gondola", "drag_lift", "aerialway_transportation", "aerialway_closed", "aerialway_goods", "goods_conveyor", "mask_terrain", "via_ferrata"], ["==", "$type", "LineString"], ["!=", "subclass", "covered_bridge"]]}, {"id": "l2_public_transport_aerialway", "type": "line", "source": "base_v1.0.0", "source-layer": "transportation", "minzoom": 10.0, "layout": {"line-cap": "round", "visibility": "visible"}, "paint": {"line-blur": ["interpolate", ["linear"], ["zoom"], 8, ["match", ["get", "class"], ["cable_car", "chair_lift", "gondola"], 0.25, ["rail"], ["match", ["get", "subclass"], ["rail"], ["match", ["get", "service"], ["yard", "siding"], 2.5, 0.6], 0.6], 0.6], 14, ["match", ["get", "class"], ["cable_car", "chair_lift", "gondola"], 0.25, ["rail"], ["match", ["get", "subclass"], ["rail"], ["match", ["get", "service"], ["yard", "siding"], 2.5, 0.6], 0.6], 0.6], 18, ["match", ["get", "class"], ["cable_car", "chair_lift", "gondola"], 0.25, 0.4]], "line-color": {"stops": [[7, "rgba(255, 50, 50, 1)"], [15, "rgba(255, 80, 80, 1)"]]}, "line-width": ["interpolate", ["linear"], ["zoom"], 9, ["match", ["get", "class"], ["cable_car", "chair_lift", "gondola"], 0.3, ["match", ["get", "is_route"], 99, ["match", ["get", "subclass"], "rail", 0.75, 0], 0]], 10, ["match", ["get", "is_route"], 99, ["match", ["get", "class"], ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail"], ["match", ["get", "service"], ["yard", "siding"], 0, 0.75], 0.5], 0.5], ["match", ["get", "class"], ["cable_car", "chair_lift", "gondola"], 0.4, ["match", ["get", "subclass"], ["cable_car", "funicular"], 0.75, 0]]], 14, ["match", ["get", "is_route"], 99, ["match", ["get", "class"], "transit", 1, ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail"], ["match", ["get", "service"], ["yard", "siding"], 0, 2], 1], 0], ["match", ["get", "class"], "cable_car", 1, ["chair_lift", "gondola"], 0.4, ["match", ["get", "subclass"], ["cable_car", "funicular"], 1, 0]]], 18, ["match", ["get", "class"], ["chair_lift", "gondola", "transit"], 1.5, ["match", ["get", "service"], ["yard", "siding"], 1.5, 2]]], "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0, 9, ["match", ["get", "class"], ["rail"], 1, 0], 12, ["match", ["get", "class"], ["rail"], 1, 0], 13, ["match", ["get", "class"], ["cable_car", "gondola", "rail"], 1, 0], 14, ["match", ["get", "class"], ["rail"], ["match", ["get", "subclass"], ["rail", "narrow_gauge", "rack_rail", "funicular"], ["match", ["get", "service"], ["yard", "siding"], 0, 1], 0], ["cable_car", "gondola", "chair_lift"], 1, 0], 14.5, 1]}, "filter": ["all", ["match", ["get", "class"], ["cable_car", "chair_lift", "gondola"], true, ["rail", "rail_construction", "transit", "transit_construction"], ["case", ["has", "layer"], [">", ["get", "layer"], 1], false], false], ["==", ["geometry-type"], "LineString"]]}, {"id": "contour_line_pt", "type": "symbol", "source": "base_v1.0.0", "source-layer": "contour_line_pt", "minzoom": 14.0, "layout": {"icon-size": ["interpolate", ["linear"], ["zoom"], 14, 0.7, 16, ["match", ["%", ["to-number", ["get", "lake_depth"]], 100], 0, 0.9, 0.7]], "text-font": ["Frutiger Neue Italic"], "text-size": ["interpolate", ["exponential", 2], ["zoom"], 14, 10.5, 16, ["case", ["has", "lake_depth"], ["match", ["%", ["to-number", ["get", "lake_depth"]], 100], 0, 14, 12], ["match", ["%", ["to-number", ["get", "ele"]], 100], 0, 14, 12]]], "icon-image": ["case", ["has", "lake_depth"], "arrow_blue", ""], "text-field": ["case", ["has", "lake_depth"], ["get", "lake_depth"], ["get", "ele"]], "visibility": "visible", "icon-anchor": "center", "icon-offset": ["case", ["has", "lake_depth"], ["match", ["length", ["to-string", ["get", "lake_depth"]]], 1, ["literal", [-12, 0]], 2, ["literal", [-16, 0]], 3, ["literal", [-20, 0]], 4, ["literal", [-24, 0]], ["literal", [0, 0]]], ["literal", [0, 0]]], "icon-rotate": ["get", "direction"], "text-anchor": "center", "text-rotate": ["get", "direction"], "text-max-angle": 35, "symbol-avoid-edges": true, "text-letter-spacing": 0.1, "icon-pitch-alignment": "map", "icon-rotation-alignment": "map", "text-rotation-alignment": "map", "text-padding": ["interpolate", ["linear"], ["zoom"], 14, 20, 15, 12, 16, 80]}, "paint": {"text-color": ["match", ["get", "class"], "scree", "rgb(110, 110, 110)", "land", "rgb(179, 136, 77)", "water", "rgb(47, 134, 188)", "ice", "rgb(80, 123, 149)", "rgb(110, 110, 110)"], "icon-opacity": ["step", ["zoom"], ["match", ["%", ["to-number", ["get", "lake_depth"]], 100], 0, 0.8, 0], 15, 0.8], "text-halo-blur": 0.25, "text-halo-color": ["match", ["get", "class"], ["ice", "water"], "rgb(215, 243, 255)", "rgba(238, 238, 238, 0.9)"], "text-halo-width": 1, "text-opacity": ["step", ["zoom"], ["case", ["has", "lake_depth"], ["match", ["%", ["to-number", ["get", "lake_depth"]], 100], 0, 1, 0], ["match", ["%", ["to-number", ["get", "ele"]], 100], 0, 1, 0]], 15, ["match", ["get", "class"], "water", 1, ["match", ["%", ["to-number", ["get", "ele"]], 100], 0, 1, 0]], 16, 1]}, "filter": ["any", ["has", "lake_depth"], ["==", ["%", ["to-number", ["get", "ele"]], 20], 0]]}, {"id": "spot_elevation", "type": "symbol", "source": "base_v1.0.0", "source-layer": "spot_elevation", "minzoom": 9.0, "layout": {"icon-optional": false, "text-optional": false, "icon-size": ["interpolate", ["linear"], ["zoom"], 13, ["match", ["get", "class"], "lake_spot_depth", 0.6, 1], 16, ["match", ["get", "class"], "lake_spot_depth", 0.6, 1]], "text-font": ["match", ["get", "class"], "lake_elevation", ["literal", ["Frutiger Neue Italic"]], ["literal", ["Frutiger Neue Regular"]]], "text-size": ["interpolate", ["linear"], ["zoom"], 13, 10, 14, 10.5, 16, 13], "icon-image": ["match", ["get", "class"], "sinkhole", "arrow_brown", ["sinkhole_ice", "sinkhole_water", "lake_spot_depth"], "arrow_blue", ["sinkhole_rock", "sinkhole_scree"], "arrow_grey", ["spot_elevation", "terrain_spot_elevation"], "dot_dark_grey_3", "lake_elevation", ["case", ["has", "lake_depth"], "arrow_line_blue", ["==", ["length", ["to-string", ["get", "ele"]]], 3], "line_blue_short", "line_blue_long"], ""], "text-field": ["match", ["get", "class"], ["spot_elevation", "terrain_spot_elevation"], ["get", "ele"], ["sinkhole", "sinkhole_rock", "sinkhole_scree", "sinkhole_water", "sinkhole_ice"], "", "lake_spot_depth", ["get", "lake_depth"], ["case", ["has", "lake_depth"], ["concat", ["get", "ele"], "\n\n", ["get", "lake_depth"]], ["get", "ele"]]], "visibility": "visible", "icon-anchor": ["match", ["get", "class"], ["sinkhole", "sinkhole_rock", "sinkhole_scree", "sinkhole_water", "sinkhole_ice"], "bottom", "center"], "icon-padding": 30, "text-anchor": ["match", ["get", "class"], "lake_spot_depth", "left", "lake_elevation", "center", "bottom-right"], "symbol-placement": "point", "text-keep-upright": false, "text-offset": ["match", ["get", "class"], "lake_spot_depth", ["literal", [0.35, 0]], "lake_elevation", ["case", ["has", "lake_depth"], ["literal", [0.35, -0.6]], ["literal", [0.1, -0.7]]], ["literal", [-0.35, 0]]], "text-justify": "center", "text-line-height": 0.9, "icon-rotate": ["match", ["get", "class"], ["sinkhole", "sinkhole_rock", "sinkhole_scree", "sinkhole_water", "sinkhole_ice"], 15, 0]}, "paint": {"text-color": ["match", ["get", "class"], ["lake_spot_depth", "lake_elevation"], "rgb(47, 134, 188)", "rgba(80, 80, 80, 1)"], "icon-opacity": ["step", ["zoom"], ["match", ["get", "class"], ["lake_elevation"], 0.8, 0], 14, ["match", ["get", "class"], ["spot_elevation", "terrain_spot_elevation", "lake_elevation"], 0.8, 0], 15, ["match", ["get", "class"], ["spot_elevation", "terrain_spot_elevation", "lake_elevation"], 0.8, ["sinkhole", "sinkhole_ice", "sinkhole_water", "sinkhole_rock", "sinkhole_scree"], 0.6, 1]], "text-opacity": ["step", ["zoom"], ["match", ["get", "class"], "lake_elevation", 0.8, 0], 14, ["match", ["get", "class"], ["spot_elevation", "terrain_spot_elevation"], 1, ["lake_elevation"], 0.8, 0], 15, ["match", ["get", "class"], "lake_elevation", 0.8, 1]], "text-halo-blur": 0.25, "text-halo-color": ["match", ["get", "class"], ["lake_spot_depth", "lake_elevation"], "rgb(199, 224, 245)", "rgba(242, 242, 242, 0.6)"], "text-halo-width": 0.5}, "filter": ["!=", "class", "doline"]}, {"id": "transportation_label", "type": "symbol", "source": "base_v1.0.0", "source-layer": "transportation_name", "minzoom": 13.0, "layout": {"text-font": ["Frutiger Neue Condensed Regular"], "text-size": ["interpolate", ["exponential", 1.5], ["zoom"], 14, ["match", ["get", "class"], ["motorway", "trunk"], 11, 10.5], 18, ["match", ["get", "class"], ["motorway", "trunk"], 18, 16]], "text-field": "{name:latin}", "visibility": "visible", "text-anchor": "center", "text-offset": [0, 0], "text-padding": 2, "symbol-spacing": ["interpolate", ["linear"], ["zoom"], 15, 200, 18, 400], "symbol-z-order": "auto", "text-max-angle": 40, "text-transform": "none", "symbol-placement": "line", "text-keep-upright": true, "symbol-avoid-edges": true, "text-letter-spacing": 0.1, "text-pitch-alignment": "map", "text-rotation-alignment": "map"}, "paint": {"text-color": ["match", ["get", "class"], ["rail", "transit", "cable_car", "gondola", "chair_lift"], "rgba(255, 50, 50, 1)", ["ferry", "car_ferry"], "rgb(47, 134, 188)", "rgba(60, 60, 70, 1)"], "text-halo-blur": 0.2, "text-halo-color": ["match", ["get", "class"], ["rail", "transit", "cable_car", "gondola", "chair_lift", "motorway", "trunk"], "rgba(255, 255, 255, 0.6)", "rgba(255, 255, 255, 0.8)"], "text-halo-width": ["interpolate", ["linear"], ["zoom"], 14, ["match", ["get", "class"], ["rail", "transit", "cable_car", "gondola", "chair_lift"], 1.5, 1.5], 20, 3]}, "filter": ["all", ["!in", "class", "cable_car", "gondola", "chair_lift", "drag_lift", "aerialway_transportation", "aerialway_closed", "aerialway_goods", "goods_conveyor"], ["==", "$type", "LineString"]]}, {"id": "road_number", "type": "symbol", "source": "base_v1.0.0", "source-layer": "transportation_name", "minzoom": 13.0, "layout": {"icon-size": 1, "text-font": ["Frutiger Neue Condensed Bold"], "text-size": 10.5, "icon-image": "box_white_grey_casing_{ref_length}", "text-field": ["get", "ref"], "visibility": "visible", "icon-offset": [0, -0.5], "text-anchor": "center", "icon-padding": 2, "text-justify": "center", "icon-optional": false, "icon-text-fit": "none", "text-optional": false, "symbol-spacing": ["step", ["zoom"], 300, 10, 600, 14, 800], "symbol-z-order": "auto", "symbol-placement": "line", "icon-keep-upright": false, "text-keep-upright": true, "icon-allow-overlap": false, "symbol-avoid-edges": false, "text-letter-spacing": 0, "icon-pitch-alignment": "viewport", "text-pitch-alignment": "viewport", "icon-ignore-placement": false, "icon-rotation-alignment": "viewport", "text-rotation-alignment": "viewport"}, "paint": {"text-color": "rgba(64, 64, 64, 1)", "icon-opacity": 1, "text-opacity": 1, "text-halo-blur": 0, "text-halo-color": "rgba(0, 0, 0, 0)", "text-halo-width": 0}, "filter": ["all", ["in", "class", "motorway", "trunk", "primary"], ["has", "ref"], ["<=", "ref_length", 12], [">", "ref_length", 0], ["==", "$type", "LineString"]]}, {"id": "area_name_glacier_point_label", "type": "symbol", "source": "base_v1.0.0", "source-layer": "area_name", "minzoom": 14.0, "layout": {"text-font": ["Frutiger Neue Italic"], "text-size": ["interpolate", ["linear"], ["zoom"], 10, 11, 16, 20], "text-field": "{name:latin}", "visibility": "visible", "symbol-spacing": {"stops": [[12, 250], [14, 550]]}, "symbol-z-order": "auto", "text-max-width": 99999, "symbol-placement": "point", "text-keep-upright": true, "symbol-avoid-edges": true, "text-allow-overlap": false, "text-letter-spacing": 0.15, "text-pitch-alignment": "map", "text-ignore-placement": false, "text-rotation-alignment": "map"}, "paint": {"text-color": "rgb(47, 134, 188)", "text-halo-blur": 1, "text-halo-color": "rgba(242, 242, 242, 0.6)", "text-halo-width": 1}, "filter": ["all", ["==", "$type", "Point"], ["in", "subclass", "glacier"]]}, {"id": "area_name_glacier_line_label", "type": "symbol", "source": "base_v1.0.0", "source-layer": "area_name", "minzoom": 12.0, "layout": {"text-font": ["Frutiger Neue Italic"], "text-size": ["interpolate", ["linear"], ["zoom"], 10, 11, 16, 20], "text-field": "{name:latin}", "visibility": "visible", "symbol-spacing": {"stops": [[12, 250], [14, 550]]}, "symbol-z-order": "auto", "text-max-width": 99999, "symbol-placement": "line-center", "text-keep-upright": true, "symbol-avoid-edges": true, "text-allow-overlap": false, "text-letter-spacing": {"stops": [[10, 0.15], [12, 0.3]]}, "text-pitch-alignment": "map", "text-ignore-placement": false, "text-rotation-alignment": "map"}, "paint": {"text-color": "rgb(47, 134, 188)", "text-halo-blur": 1, "text-halo-color": "rgba(242, 242, 242, 0.6)", "text-halo-width": 1}, "filter": ["all", ["==", "$type", "LineString"], ["in", "subclass", "glacier"]]}, {"id": "poi_rank2", "type": "symbol", "source": "base_v1.0.0", "source-layer": "poi", "minzoom": 14.0, "layout": {"icon-size": 1, "text-font": ["Frutiger Neue Condensed Regular"], "text-size": ["interpolate", ["linear"], ["zoom"], 14, 12, 18, 18], "icon-image": ["match", ["get", "subclass"], ["aerialway_station", "bus_stop", "cable_car_station", "chair_lift_station", "elevator", "funicular_stop", "gondola_station", "halt", "subway_entrance", "subway_stop", "tram_stop"], "dot_red", ["camp_site", "caravan_site"], "camping_grey", ["car_ferry", "ferry", "ferry_terminal"], "dot_blue", "church_tower", "dot_circle_dark_grey_12", "communications_tower", "communications_tower_grey", ["golf", "golf_course"], "golf_grey", "hospital", "hospital_grey", "observation_tower", "observation_tower_grey", ["survey_point", "surveying_pyramid"], "dot_dark_grey_4", "waterfall", "waterfall_blue", "wind_turbine", "windturbine_grey", ""], "text-field": ["case", ["has", "name:latin"], ["get", "name:latin"], ""], "visibility": "visible", "icon-rotate": ["case", ["has", "direction"], ["get", "direction"], 0], "text-anchor": ["match", ["get", "subclass"], ["caravan_site", "camp_site", "church_tower", "golf_course", "golf", "hospital", "communications_tower", "waterfall", "observation_tower"], "left", ["ferry", "car_ferry", "ferry_terminal", "funicular_stop", "halt", "tram_stop", "bus_stop", "cable_car_station", "gondola_station", "chair_lift_station", "aerialway_station", "elevator", "subway_entrance", "subway_stop", "survey_point", "surveying_pyramid"], "bottom-left", "center"], "text-offset": ["match", ["get", "subclass"], ["ferry", "car_ferry", "ferry_terminal", "funicular_stop", "halt", "tram_stop", "bus_stop", "cable_car_station", "gondola_station", "chair_lift_station", "aerialway_station", "elevator", "subway_entrance", "subway_stop"], ["literal", [0.4, 0.1]], "observation_tower", ["literal", [0.5, 0.2]], "waterfall", ["literal", [0.5, 0.1]], ["literal", [0.8, 0.1]]], "text-justify": ["match", ["get", "subclass"], ["aerialway_station", "bus_stop", "cable_car_station", "chair_lift_station", "elevator", "funicular_stop", "gondola_station", "halt", "subway_entrance", "subway_stop", "tram_stop", "camp_site", "caravan_site", "car_ferry", "ferry", "ferry_terminal", "church_tower", "communications_tower", "golf", "golf_course", "hospital", "observation_tower", "survey_point", "surveying_pyramid", "waterfall", "wind_turbine"], "left", "center"], "icon-optional": false, "text-max-width": 10, "icon-allow-overlap": false, "icon-ignore-placement": false, "text-letter-spacing": ["match", ["get", "class"], ["park"], 0.05, 0], "icon-rotation-alignment": "map"}, "paint": {"text-color": ["match", ["get", "subclass"], ["ferry", "car_ferry", "ferry_terminal"], "rgb(47, 134, 188)", ["funicular_stop", "halt", "tram_stop", "bus_stop", "cable_car_station", "gondola_station", "chair_lift_station", "aerialway_station", "elevator", "subway_entrance", "subway_stop"], "rgba(255, 50, 50, 1)", ["weir", "waterfall"], "rgb(47, 134, 188)", "rgba(48, 48, 48, 1)"], "icon-opacity": ["step", ["zoom"], 0, 14, ["match", ["get", "class"], ["place_of_worship"], 1, ["match", ["get", "subclass"], ["wind_turbine", "caravan_site", "camp_site", "ferry", "ferry_terminal", "funicular_stop", "golf_course", "golf", "cemetery", "stadium", "park"], 1, 0]], 15, 1], "text-opacity": ["step", ["zoom"], 0, 14, ["match", ["get", "class"], ["place_of_worship"], 1, ["match", ["get", "subclass"], ["wind_turbine", "caravan_site", "camp_site", "ferry", "ferry_terminal", "funicular_stop", "golf_course", "golf", "cemetery", "stadium", "park"], 1, 0]], 15, 1], "text-halo-blur": 1, "text-halo-color": "rgba(242, 242, 242, 0.6)", "text-halo-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 17, 2]}, "filter": ["any", ["match", ["get", "class"], ["place_of_worship", "monastery"], true, false], ["match", ["get", "subclass"], ["aerialway", "aerialway_station", "allottments", "antenna_area", "attraction", "building", "bus_stop", "cable_car_station", "camp_site", "cave", "cemetery", "chair_lift_station", "church_tower", "communications_tower", "dam", "driving_centre", "elevator", "entrance", "ferry", "ferry_terminal", "fuel", "funicular", "funicular_stop", "garden", "golf", "golf_course", "gondola_station", "halt", "horse_racing", "incineration_plant", "military", "monastery", "observation_tower", "observatory", "park", "pitch", "power", "power_plant", "prison", "ruins", "shooting", "solar", "sports_centre", "stadium", "subway_entrance", "subway_stop", "survey_point", "swimming_pool", "tower", "tram_stop", "wastewater_plant", "waterfall", "weir", "wind_turbine", "zoo"], true, false]]}, {"id": "peaks_rank2", "type": "symbol", "source": "base_v1.0.0", "source-layer": "mountain_peak", "minzoom": 10.0, "layout": {"icon-size": 1, "text-font": ["Frutiger Neue Italic"], "text-size": ["interpolate", ["linear"], ["zoom"], 10, ["case", ["<=", 4000, ["get", "ele"]], 14, ["<=", 3000, ["get", "ele"]], 13, ["<=", 2000, ["get", "ele"]], 12, 11], 18, ["case", ["<=", 4000, ["get", "ele"]], 23, ["<=", 3000, ["get", "ele"]], 22, ["<=", 2000, ["get", "ele"]], 21, 20]], "icon-image": ["step", ["zoom"], "", 10, "dot_dark_grey_4", 14, "dot_dark_grey_6", 18, "dot_dark_grey_8"], "text-field": ["format", ["get", "name:latin"], {}, "\n", {}, ["get", "ele"], {"font-scale": 0.75}], "visibility": "visible", "icon-anchor": "center", "text-anchor": "bottom", "icon-padding": 0, "text-justify": "auto", "text-padding": 2, "icon-optional": false, "text-optional": false, "symbol-z-order": ["literal", "auto"], "text-max-width": 8, "symbol-placement": "point", "text-line-height": 1.15, "icon-allow-overlap": false, "text-allow-overlap": false, "text-radial-offset": 0.3, "text-letter-spacing": 0.025, "icon-pitch-alignment": "auto", "icon-ignore-placement": false, "text-ignore-placement": false, "icon-rotation-alignment": "map"}, "paint": {"icon-color": "rgba(0, 0, 0, 1)", "text-color": "rgba(48, 48, 48, 1)", "icon-opacity": ["step", ["zoom"], 0, 8, ["match", ["get", "class"], "alpine_peak", 1, 0], 10, ["match", ["get", "class"], "alpine_peak", ["case", [">=", 2, ["get", "rank"]], 1, 0], 0], 11, ["match", ["get", "class"], ["alpine_peak", "main_peak"], ["case", [">=", 3, ["get", "rank"]], 1, 0], 0], 12, ["match", ["get", "class"], ["alpine_peak", "main_peak", "peak", "main_hill", "hill"], ["case", [">=", 4, ["get", "rank"]], 1, 0], 0], 13, ["match", ["get", "class"], ["alpine_peak", "main_peak", "peak", "main_hill", "hill"], 1, 0], 15, 1], "text-opacity": ["step", ["zoom"], 0, 8, ["match", ["get", "class"], "alpine_peak", 1, 0], 10, ["match", ["get", "class"], "alpine_peak", ["case", [">=", 2, ["get", "rank"]], 1, 0], 0], 11, ["match", ["get", "class"], ["alpine_peak", "main_peak"], ["case", [">=", 3, ["get", "rank"]], 1, 0], 0], 12, ["match", ["get", "class"], ["alpine_peak", "main_peak", "peak", "main_hill", "hill"], ["case", [">=", 4, ["get", "rank"]], 1, 0], 0], 13, ["match", ["get", "class"], ["alpine_peak", "main_peak", "peak", "main_hill", "hill"], 1, 0], 15, 1], "icon-halo-blur": 0, "text-halo-blur": 1, "icon-halo-color": "rgba(255, 255, 255, 1)", "icon-halo-width": 0, "text-halo-color": "rgba(242, 242, 242, 0.6)", "text-halo-width": ["interpolate", ["linear"], ["zoom"], 12, 1.5, 18, 3]}, "filter": ["all", ["has", "rank"], [">", "rank", 1], ["!in", "class", "mountain_pass"]]}, {"id": "place_other", "type": "symbol", "source": "base_v1.0.0", "source-layer": "place", "minzoom": 12.0, "layout": {"icon-size": 1, "text-font": ["Frutiger Neue Condensed Medium"], "text-size": ["interpolate", ["cubic-bezier", 0.5, 0.1, 0.7, 1], ["zoom"], 4, ["match", ["get", "class"], ["hamlet", "isolated_dwelling"], ["case", ["<=", 50, ["get", "population"]], 6, 4], ["case", ["<=", 5000, ["get", "population"]], 8, ["<=", 2000, ["get", "population"]], 6, 4]], 16, ["match", ["get", "class"], ["hamlet", "isolated_dwelling"], ["case", ["<=", 50, ["get", "population"]], 20, 18], ["case", ["<=", 5000, ["get", "population"]], 22, ["<=", 2000, ["get", "population"]], 20, 18]]], "icon-image": ["step", ["zoom"], ["match", ["get", "class"], ["hamlet", "isolated_dwelling"], "circle_dark_grey_4", ""], 12, ["match", ["get", "class"], ["hamlet", "isolated_dwelling"], "circle_dark_grey_6", ""]], "text-field": ["get", "name:latin"], "visibility": "visible", "text-anchor": ["match", ["get", "class"], ["hamlet", "isolated_dwelling"], "bottom-left", "center"], "text-offset": ["match", ["get", "class"], ["hamlet", "isolated_dwelling"], ["literal", [0.3, 0.2]], ["literal", [0, 0]]], "text-justify": "auto", "text-padding": ["interpolate", ["linear"], ["zoom"], 10, 2, 13, 2, 14, 0], "icon-optional": false, "symbol-z-order": ["literal", "auto"], "text-max-width": 10, "text-transform": "none", "symbol-sort-key": ["to-number", ["get", "rank"]], "text-letter-spacing": 0.025, "icon-anchor": "center"}, "paint": {"text-color": "rgba(64, 64, 64, 1)", "icon-opacity": ["step", ["zoom"], 0, 11, ["match", ["get", "class"], ["hamlet", "isolated_dwelling"], ["case", [">", 30, ["get", "rank"]], 1, 0], ["case", [">", 18, ["get", "rank"]], 1, 0]], 13, ["match", ["get", "class"], ["hamlet", "isolated_dwelling"], ["case", [">", 31, ["get", "rank"]], 1, 0], 1], 14, 0], "text-opacity": ["step", ["zoom"], 0, 11, ["match", ["get", "class"], ["hamlet", "isolated_dwelling"], ["case", [">", 30, ["get", "rank"]], 1, 0], 0], 13, ["match", ["get", "class"], ["hamlet", "isolated_dwelling"], ["case", [">", 31, ["get", "rank"]], 1, 0], 0], 14, 1], "text-halo-blur": 1, "text-halo-color": "rgba(242, 242, 242, 0.6)", "text-halo-width": ["interpolate", ["cubic-bezier", 0, 0.75, 0.25, 1], ["zoom"], 12, 1, 16, 2]}, "filter": ["!in", "class", "continent", "country", "state", "city", "town", "village"]}, {"id": "poi_rank1", "type": "symbol", "source": "base_v1.0.0", "source-layer": "poi", "minzoom": 11.0, "layout": {"icon-size": 1, "text-font": ["Frutiger Neue Condensed Regular"], "text-size": ["interpolate", ["linear"], ["zoom"], 13, ["match", ["get", "class"], "hazard", 15, 13], 18, ["match", ["get", "class"], "hazard", 24, 20]], "icon-image": ["match", ["get", "subclass"], "railway_station", "square_red", "castle", "castle_grey", ""], "text-field": ["case", ["has", "name:latin"], ["get", "name:latin"], ""], "visibility": "visible", "text-anchor": ["match", ["get", "subclass"], ["castle"], "left", "railway_station", "bottom-left", "center"], "text-offset": ["match", ["get", "subclass"], "railway_station", ["literal", [0.5, 0.15]], ["literal", [0.8, 0.15]]], "text-justify": "left", "text-padding": 10, "text-optional": false, "text-max-width": 10, "symbol-avoid-edges": true, "icon-rotation-alignment": "map"}, "paint": {"text-color": ["match", ["get", "subclass"], "railway_station", "rgba(255, 50, 50, 1)", ["match", ["get", "class"], "hazard", "rgb(139, 96, 37)", "rgba(48, 48, 48, 1)"]], "text-opacity": ["step", ["zoom"], ["match", ["get", "class"], "hazard", 1, 0], 14, 1], "text-halo-blur": 1, "text-halo-color": "rgba(242, 242, 242, 0.6)", "text-halo-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 17, 2]}, "filter": ["any", ["in", "subclass", "castle", "railway_station"], ["==", "class", "hazard"]]}, {"id": "aerodrome_label", "type": "symbol", "source": "base_v1.0.0", "source-layer": "aerodrome_label", "minzoom": 11.0, "layout": {"icon-size": ["interpolate", ["linear"], ["zoom"], 11, 0.7, 14, 1], "text-font": ["Frutiger Neue Condensed Regular"], "text-size": ["interpolate", ["linear"], ["zoom"], 11, ["match", ["get", "class"], "international", 16, "regional", 16, 12], 14, ["match", ["get", "class"], "international", 20, "regional", 18, 16]], "icon-image": ["match", ["get", "class"], "international", "airplane_large_grey", "regional", "airplane_medium_grey", "other", "airplane_small_grey", "helipad", "helicopter_grey", ""], "text-field": ["get", "name:latin"], "visibility": "visible", "icon-anchor": "center", "text-anchor": "left", "text-offset": ["match", ["get", "class"], "helipad", ["literal", [0.9, 0.2]], ["literal", [0.9, 0]]], "text-justify": "center", "text-padding": 2, "text-transform": "none"}, "paint": {"text-color": "rgba(64, 64, 64, 1)", "icon-opacity": ["step", ["zoom"], 0, 11, ["match", ["get", "class"], ["international", "regional"], 1, 0], 12, 1], "text-opacity": ["step", ["zoom"], 0, 11, ["match", ["get", "class"], ["international", "regional"], 1, 0], 12, 1], "text-halo-blur": 1, "text-halo-color": "rgba(242, 242, 242, 0.8)", "text-halo-width": ["interpolate", ["linear"], ["zoom"], 14, 1, 17, 2]}, "filter": ["all", ["!=", "class", "helipad"]]}, {"id": "place_town_village", "type": "symbol", "source": "base_v1.0.0", "source-layer": "place", "minzoom": 6.0, "maxzoom": 16.0, "layout": {"icon-size": 1, "text-font": ["match", ["get", "class"], "town", ["literal", ["Frutiger Neue Condensed Bold"]], ["literal", ["Frutiger Neue Condensed Medium"]]], "text-size": ["interpolate", ["cubic-bezier", 0.5, 0.1, 0.7, 1], ["zoom"], 4, 11, 10, ["match", ["get", "class"], "town", ["case", ["<=", 30000, ["get", "population"]], 20, 18], ["case", ["<=", 3000, ["get", "population"]], 16, 14]], 16, ["match", ["get", "class"], "town", ["case", ["<=", 30000, ["get", "population"]], 32, 28], ["case", ["<=", 3000, ["get", "population"]], 28, 24]]], "icon-image": ["step", ["zoom"], "circle_dark_grey_4", 6, ["match", ["get", "class"], "town", "circle_dark_grey_6", "circle_dark_grey_4"], 8, ["match", ["get", "class"], "town", "circle_dark_grey_8", "circle_dark_grey_6"], 10, ["match", ["get", "class"], "town", "circle_dark_grey_10", "circle_dark_grey_8"], 12, ["match", ["get", "class"], "town", "circle_dark_grey_12", "circle_dark_grey_10"]], "text-field": ["get", "name:latin"], "visibility": "visible", "text-anchor": ["literal", "bottom-left"], "text-radial-offset": 0.3, "text-justify": "auto", "text-padding": 2, "icon-optional": false, "symbol-z-order": ["literal", "auto"], "text-max-width": 10, "text-transform": ["match", ["get", "class"], "town", "uppercase", "none"], "symbol-sort-key": ["to-number", ["get", "rank"]], "symbol-placement": "point", "text-variable-anchor": ["literal", ["bottom-left", "top-left", "bottom-right", "top-right", "left", "right"]], "text-letter-spacing": 0.025}, "paint": {"text-color": "rgba(64, 64, 64, 1)", "icon-opacity": ["step", ["zoom"], 0, 6, ["match", ["get", "class"], "town", ["case", [">", 9, ["get", "rank"]], 1, ["match", ["get", "capital"], [2, 4], 1, 0]], 0], 7, ["match", ["get", "class"], "town", ["case", [">", 9, ["get", "rank"]], 1, ["match", ["get", "capital"], [2, 4], 1, 0]], ["match", ["get", "capital"], [2, 4], 1, 0]], 8, ["match", ["get", "class"], "town", 1, ["case", [">", 14, ["get", "rank"]], 1, 0]], 9, ["match", ["get", "class"], "town", 1, ["case", [">", 15, ["get", "rank"]], 1, 0]], 10, ["match", ["get", "class"], "town", 1, ["case", [">", 18, ["get", "rank"]], 1, 0]], 11, ["match", ["get", "class"], "town", 1, ["case", [">", 28, ["get", "rank"]], 1, 0]], 12, ["match", ["get", "class"], "town", 0, 1], 13, 0], "text-opacity": ["step", ["zoom"], 0, 6, ["match", ["get", "class"], "town", ["case", [">", 9, ["get", "rank"]], 1, ["match", ["get", "capital"], [2, 4], 1, 0]], 0], 7, ["match", ["get", "class"], "town", ["case", [">", 9, ["get", "rank"]], 1, ["match", ["get", "capital"], [2, 4], 1, 0]], ["match", ["get", "capital"], [2, 4], 1, 0]], 8, ["match", ["get", "class"], "town", 1, ["case", [">", 14, ["get", "rank"]], 1, 0]], 9, ["match", ["get", "class"], "town", 1, ["case", [">", 15, ["get", "rank"]], 1, 0]], 10, ["match", ["get", "class"], "town", 1, ["case", [">", 18, ["get", "rank"]], 1, 0]], 11, ["match", ["get", "class"], "town", 1, ["case", [">", 28, ["get", "rank"]], 1, 0]], 12, 1], "text-halo-blur": 1, "text-halo-color": "rgba(242, 242, 242, 0.6)", "text-halo-width": ["interpolate", ["cubic-bezier", 0, 0.75, 0.25, 1], ["zoom"], 12, 1, 16, ["match", ["get", "class"], "town", 3, 2]]}, "filter": ["in", "class", "town", "village"]}, {"id": "peaks_rank1", "type": "symbol", "source": "base_v1.0.0", "source-layer": "mountain_peak", "minzoom": 9.0, "layout": {"icon-size": 1, "text-font": ["Frutiger Neue Italic"], "text-size": ["interpolate", ["linear"], ["zoom"], 10, ["case", ["<=", 4000, ["get", "ele"]], 14, ["<=", 3000, ["get", "ele"]], 13, ["<=", 2000, ["get", "ele"]], 12, 11], 18, ["case", ["<=", 4000, ["get", "ele"]], 23, ["<=", 3000, ["get", "ele"]], 22, ["<=", 2000, ["get", "ele"]], 21, 20]], "icon-image": ["step", ["zoom"], "dot_dark_grey_4", 14, "dot_dark_grey_6", 18, "dot_dark_grey_8"], "text-field": ["format", ["get", "name:latin"], {}, "\n", {}, ["get", "ele"], {"font-scale": 0.75}], "visibility": "visible", "icon-anchor": "center", "icon-offset": [0, 0], "text-anchor": "bottom", "icon-padding": 2, "text-justify": "auto", "text-padding": 10, "icon-optional": false, "text-optional": false, "symbol-z-order": ["literal", "auto"], "text-max-width": 8, "symbol-placement": "point", "text-line-height": 1.15, "icon-allow-overlap": false, "text-allow-overlap": false, "text-radial-offset": 0.3, "text-letter-spacing": 0.025, "icon-pitch-alignment": "auto", "icon-ignore-placement": false, "text-ignore-placement": false, "icon-rotation-alignment": "map"}, "paint": {"icon-color": "rgba(0, 0, 0, 1)", "text-color": "rgba(48, 48, 48, 1)", "icon-opacity": ["step", ["zoom"], 0, 9, ["case", ["<=", 3200, ["get", "ele"]], 1, 0], 10, 1], "text-opacity": ["step", ["zoom"], 0, 9, ["case", ["<=", 3200, ["get", "ele"]], 1, 0], 10, 1], "icon-halo-blur": 0, "text-halo-blur": 1, "icon-halo-color": "rgba(255, 255, 255, 1)", "icon-halo-width": 0, "text-halo-color": "rgba(242, 242, 242, 0.6)", "text-halo-width": ["interpolate", ["linear"], ["zoom"], 10, 1.5, 18, 3]}, "filter": ["all", ["has", "rank"], ["==", "rank", 1], ["!in", "class", "rocky_knoll", "saddle", "mountain_pass"]]}, {"id": "waterway_line_label", "type": "symbol", "source": "base_v1.0.0", "source-layer": "waterway", "minzoom": 9.0, "layout": {"text-font": ["Frutiger Neue Italic"], "text-size": ["interpolate", ["exponential", 1.5], ["zoom"], 10, 10.5, 16, ["match", ["to-string", ["get", "width"]], ["9", "10"], 18, ["7", "8"], 17, ["5", "6"], 16, "4", 15, "3", 14, "2", 13, "1", 12, ["match", ["get", "class"], ["river", "canal"], 18, 14]]], "text-field": ["get", "name:latin"], "visibility": "visible", "text-padding": 0, "symbol-spacing": 650, "symbol-z-order": "auto", "text-max-width": 9999, "symbol-placement": "line", "text-keep-upright": true, "text-allow-overlap": false, "text-letter-spacing": ["interpolate", ["linear"], ["zoom"], 8, 0.1, 12, ["match", ["get", "class"], ["river"], 0.4, 0.2]], "text-pitch-alignment": "map", "text-ignore-placement": false, "text-rotation-alignment": "map"}, "paint": {"text-color": "rgb(47, 134, 188)", "text-halo-blur": 1, "text-halo-color": "rgba(242, 242, 242, 0.6)", "text-halo-width": 2}, "filter": ["all", ["has", "name:latin"], ["case", ["has", "intermittent"], ["!=", ["get", "intermittent"], 1], true], ["==", ["geometry-type"], "LineString"]]}, {"id": "water_name_point_label", "type": "symbol", "source": "base_v1.0.0", "source-layer": "water_name", "minzoom": 7.0, "layout": {"text-font": ["Frutiger Neue Italic"], "text-size": ["interpolate", ["linear"], ["zoom"], 8, ["match", ["get", "size"], [10, 9], 12, [8, 7], 10, 10], 10, ["match", ["get", "size"], [10, 9], 22, [8, 7, 6], 18, [5, 4], 16, [3], 14, [2], 12, [1], 10, 10], 12, ["match", ["get", "size"], [10, 9], 24, [8, 7], 22, [6, 5, 4, 3], 20, [2], 16, [1], 12, 12], 14, ["match", ["get", "size"], [10, 9], 28, [8, 7], 26, [6, 5, 4, 3], 24, [2], 22, [1], 14, 12], 15, ["match", ["get", "size"], [10, 9], 30, [8, 7], 28, [6, 5, 4, 3], 26, [2], 24, [1], 18, 18]], "text-field": "{name:latin}", "visibility": "visible", "text-rotate": ["get", "direction"], "text-padding": 2, "symbol-spacing": 250, "symbol-z-order": "auto", "text-max-width": 20, "symbol-placement": "point", "text-keep-upright": true, "text-allow-overlap": false, "text-letter-spacing": ["interpolate", ["linear"], ["zoom"], 7, ["match", ["get", "size"], [10, 9, 8], 0.1, [7, 6], 0.04, [5, 4, 3], 0.02, [2], 0.01, 0.05], 16, ["match", ["get", "size"], [10, 9, 8], 0.7, [7, 6], 0.5, [5, 4, 3], 0.3, [2], 0.2, 0.1]], "text-pitch-alignment": "map", "text-ignore-placement": false, "text-rotation-alignment": "viewport"}, "paint": {"text-color": "rgb(47, 134, 188)", "text-opacity": ["step", ["zoom"], 0, 7, ["match", ["get", "size"], [10, 9, 8], 1, 0], 8, ["match", ["get", "size"], [10, 9, 8, 7, 6], 1, 0], 9, ["match", ["get", "size"], [10, 9, 8, 7, 6, 4, 3], 1, 0], 10, ["match", ["get", "size"], [10, 9, 8, 7, 6, 5, 4, 3, 2], 1, 0], 12, ["match", ["get", "size"], [10, 9, 8, 7, 6, 5, 4, 3, 2, 1], 1, 1]], "text-halo-blur": 0.25, "text-halo-color": "rgba(242, 242, 242, 0.6)", "text-halo-width": 1}, "filter": ["all", ["==", "$type", "Point"], ["==", "class", "lake"]]}, {"id": "park_label", "type": "symbol", "source": "base_v1.0.0", "source-layer": "park", "minzoom": 8.0, "layout": {"text-font": ["Frutiger Neue Regular"], "text-size": ["interpolate", ["cubic-bezier", 0.5, 0.1, 0.7, 1], ["zoom"], 8, 14, 16, 28], "text-field": "{name:latin}", "visibility": "visible", "text-padding": ["interpolate", ["linear"], ["zoom"], 10, 2, 14, 10], "symbol-z-order": ["literal", "auto"]}, "paint": {"text-color": "rgba(70, 130, 25, 0.9)", "text-halo-blur": 1, "text-halo-color": "rgba(255, 255, 255, 0.8)", "text-halo-width": 1}, "filter": ["all", ["in", "class", "national_park"], ["==", "$type", "Point"]]}, {"id": "area_name_massif_label", "type": "symbol", "source": "base_v1.0.0", "source-layer": "area_name", "minzoom": 11.0, "layout": {"text-font": ["Frutiger Neue Regular"], "text-size": ["interpolate", ["linear"], ["zoom"], 10, 14, 16, 23], "text-field": "{name:latin}", "visibility": "visible", "symbol-spacing": {"stops": [[12, 250], [14, 550]]}, "symbol-z-order": "auto", "text-max-width": 99999, "symbol-placement": "point", "text-keep-upright": true, "symbol-avoid-edges": true, "text-allow-overlap": false, "text-letter-spacing": 0.07, "text-pitch-alignment": "viewport", "text-ignore-placement": false, "text-rotation-alignment": "viewport"}, "paint": {"text-color": "rgba(48, 48, 48, 1)", "text-halo-blur": 1, "text-halo-color": "rgba(242, 242, 242, 0.6)", "text-halo-width": 1}, "filter": ["all", ["==", "$type", "Point"], ["in", "subclass", "massif"]]}, {"id": "place_city", "type": "symbol", "source": "base_v1.0.0", "source-layer": "place", "minzoom": 2.0, "maxzoom": 14.0, "layout": {"icon-size": 1, "text-font": ["Frutiger Neue Condensed Bold"], "text-size": ["interpolate", ["cubic-bezier", 0.5, 0.1, 0.7, 1], ["zoom"], 1, 11, 4, 12, 16, 48], "icon-image": ["step", ["zoom"], "dot_circle_dark_grey_6", 6, "dot_circle_dark_grey_8", 8, "circle_circle_dark_grey_10", 10, "circle_circle_dark_grey_12", 12, "circle_circle_dark_grey_12"], "text-field": ["get", "name:latin"], "visibility": "visible", "text-anchor": "bottom-left", "text-offset": ["literal", [0.35, 0.1]], "text-justify": "auto", "text-padding": 10, "icon-optional": false, "symbol-z-order": ["literal", "auto"], "text-max-width": 10, "text-transform": "uppercase", "symbol-sort-key": ["to-number", ["get", "rank"]], "text-letter-spacing": 0.025}, "paint": {"text-color": "rgba(64, 64, 64, 1)", "icon-opacity": ["step", ["zoom"], 1, 11, 0], "text-opacity": 1, "text-halo-blur": 1, "text-halo-color": "rgba(242, 242, 242, 0.6)", "text-halo-width": ["interpolate", ["cubic-bezier", 0, 0.75, 0.25, 1], ["zoom"], 12, 1, 16, 3]}, "filter": ["all", ["==", "class", "city"]]}, {"id": "hazard", "type": "fill", "source": "base_v1.0.0", "source-layer": "landcover", "minzoom": 5.0, "layout": {"visibility": "visible"}, "paint": {"fill-color": "rgb(240, 218, 188)", "fill-opacity": 0.4, "fill-antialias": false}, "filter": ["==", "class", "hazard"]}, {"id": "place_country", "type": "symbol", "source": "base_v1.0.0", "source-layer": "place", "minzoom": 0.0, "maxzoom": 14.0, "layout": {"text-font": ["Frutiger Neue Condensed Regular"], "text-size": ["interpolate", ["linear"], ["zoom"], 1, 12, 7, 14, 11, 16, 18, 24], "text-field": "{name:latin}", "visibility": "visible", "text-max-width": 10, "text-transform": "uppercase", "text-letter-spacing": 0.12}, "paint": {"text-color": "rgba(145, 70, 145, 0.9)", "text-halo-blur": 1, "text-halo-color": "rgba(255, 255, 255, 0.8)", "text-halo-width": 1}, "filter": ["==", "class", "country"]}], "metadata": {"maptiler:template": {"groups": [{"id": "4rZcx2YUdbGIJ_xQaDQb6", "name": "label", "icon": "variant2", "layers": ["place_country", "place_city", "area_name_massif_label", "park_label", "water_name_point_label", "area_name_glacier_line_label", "area_name_glacier_point_label", "place_town_village", "place_other"]}, {"id": "spbW1YO3D6JmGqVeeVEo3", "name": "poi", "icon": "poi", "layers": ["poi_rank1", "poi_rank2"]}, {"id": "Egb2j0u2wyQyI8FftRcqF", "name": "mountain_peak", "icon": "terrain", "layers": ["peaks_rank1", "peaks_rank2"]}, {"id": "o-n3H-7f0sOIKwDFxFRaG", "name": "infrastructure", "icon": "builtUp", "layers": ["building_casing", "building", "building_ln", "construct", "construct_line"]}, {"id": "_olyvAt6o8QUN9qePUZap", "name": "transportation", "icon": "transport", "layers": ["road_number", "transportation_label", "l2_fill", "l2_road_casing", "l1_public_transport", "l1_fill", "l1_road_casing", "public_transport", "road_fill", "road_casing", "road_via_ferrata_trail", "tunnel_road", "tunnel_pedestrian", "tunnel_public_transport", "aerodrome_label", "aeroway_polygon_fill", "aeroway_polygon_casing", "landuse_parking", "landuse_parking_outline", "l2_public_transport_aerialway", "l1_2_pedestrian", "road_path_footway_ferry"]}, {"id": "z959Ue-RzB_dh5_EJgGry", "name": "boundary", "icon": "administrative", "layers": ["boundary", "boundary_disputed", "park"]}, {"id": "jQ4N1YUSpDNjoXrIHGNpp", "name": "water", "icon": "water", "layers": ["waterway_line_label", "water_outline", "water_line_intermittent", "water", "contour_line_blue", "water_line"]}, {"id": "QZWB1txCT3r3H5wy13N86", "name": "land", "icon": "nature", "layers": ["pattern_landcover", "landcover", "landcover_casing", "landuse_outline", "landuse", "hazard"]}, {"id": "terrain", "name": "terrain", "icon": "terrain", "layers": ["hillshade_grey", "hillshade_yellow", "hachure", "scree_z11", "scree_z13", "scree_z15", "scree_z17", "contour_line", "spot_elevation", "contour_line_pt"]}, {"id": "background", "name": "Background", "icon": "background", "layers": ["background"]}], "viewState": {"toolPanelType": "Layers", "mapType": "Style", "layersViewPanelType": "Verticality", "propertyPanelType": "Edit", "activeBlock": "QZWB1txCT3r3H5wy13N86", "selectedLayers": ["hazard"], "editorFocusProperty": null}, "userSettings": {"preferredViewPanelType": "Verticality"}, "spaceColor": "#F7FBFD"}, "ga:version": "v1.25.1", "ga:publisher": "CI-release:1b6976d1-03ac-41f1-8f4a-7346256d7d81", "ga:branch": "master"}, "glyphs": "https://vectortiles.geo.admin.ch/fonts/{fontstack}/{range}.pbf", "sprite": "https://vectortiles.geo.admin.ch/styles/ch.swisstopo.lightbasemap.vt/v1.25.1/sprite/sprite", "bearing": 0.0, "pitch": 0.0, "center": [8.349961425056108, 46.81133904939108], "zoom": 7.2512975045452235, "transition": {}} \ No newline at end of file diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/ComposeTestTemplate.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/ComposeTestTemplate.kt new file mode 100644 index 00000000..3a1acde2 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/ComposeTestTemplate.kt @@ -0,0 +1,30 @@ +package ovh.plrapps.mapcompose.vector + +/* +@OptIn(ExperimentalTestApi::class) +class ComposeTest { + + @Test + fun simpleCheck() = runComposeUiTest { + setContent { + var txt by remember { mutableStateOf("Go") } + Column { + Text( + text = txt, + modifier = Modifier.testTag("t_text") + ) + Button( + onClick = { txt += "." }, + modifier = Modifier.testTag("t_button") + ) { + Text("click me") + } + } + } + + onNodeWithTag("t_button").apply { + repeat(3) { performClick() } + } + onNodeWithTag("t_text").assertTextEquals("Go...") + } +}*/ diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/renderer/GeometryDecodersTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/renderer/GeometryDecodersTest.kt new file mode 100644 index 00000000..d36a3ea8 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/renderer/GeometryDecodersTest.kt @@ -0,0 +1,27 @@ +import kotlin.test.Test +import kotlin.test.assertEquals +import ovh.plrapps.mapcompose.vector.renderer.GeometryDecoders.Companion.tileCoordToCanvas +import ovh.plrapps.mapcompose.vector.renderer.GeometryDecoders.Companion.decodeZigZag + +class GeometryDecodersTest { + private fun encodeZigZag(n: Int): Int = (n shl 1) xor (n shr 31) + + @Test + fun testDecodeZigZag() { + val values = listOf(0, 1, -1, 2, -2, 123456, -123456, Int.MAX_VALUE / 2, -(Int.MAX_VALUE / 2) - 1) + for (n in values) { + val encoded = encodeZigZag(n) + assertEquals(n, decodeZigZag(encoded), "decodeZigZag(encodeZigZag($n))") + } + } + + @Test + fun testTileCoordToCanvas() { + // canvasSize = 256, extent = 4096, scale = 0.0625 + assertEquals(Pair(0f, 0f), tileCoordToCanvas(0, 0, 256, 4096)) + assertEquals(Pair(62.5f, 125f), tileCoordToCanvas(1000, 2000, 256, 4096)) + assertEquals(Pair(-6.25f, -6.25f), tileCoordToCanvas(-100, -100, 256, 4096)) + assertEquals(Pair(0f, -16f), tileCoordToCanvas(0, -16, 16, 16)) + assertEquals(Pair(250.625f, -1f), tileCoordToCanvas(4010, -16, 256, 4096)) + } +} \ No newline at end of file diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/CollisionDetectorTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/CollisionDetectorTest.kt new file mode 100644 index 00000000..17c42a3e --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/CollisionDetectorTest.kt @@ -0,0 +1,128 @@ +package ovh.plrapps.mapcompose.vector.renderer.collision + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.test.ExperimentalTestApi +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import ovh.plrapps.mapcompose.vector.utils.obb.OBB +import ovh.plrapps.mapcompose.vector.utils.obb.ObbPoint +import ovh.plrapps.mapcompose.vector.utils.obb.Size as ObbSize + +@OptIn(ExperimentalTestApi::class) +class CollisionDetectorTest { + private fun label( + id: String, + x: Float, y: Float, w: Float, h: Float, + overlapMode: OverlapMode = OverlapMode.Never, + ignorePlacement: Boolean = false, + ) = LabelPlacement( + text = id, + position = ObbPoint(x + w / 2, y + h / 2), + angle = 0f, + bounds = Rect(x, y, x + w, y + h), + obb = OBB( + center = ObbPoint(x + w / 2, y + h / 2), + size = ObbSize(w, h), + rotation = 0f + ), + layerIndex = 0, + inLayerPriority = 0.0, + overlapMode = overlapMode, + ignorePlacement = ignorePlacement, + ) + + @Test + fun testIntersectsTrue() { + val r1 = Rect(0f, 0f, 10f, 10f) + val r2 = Rect(5f, 5f, 15f, 15f) + assertTrue(r1.intersects(r2)) + } + + @Test + fun testIntersectsFalse() { + val r1 = Rect(0f, 0f, 10f, 10f) + val r2 = Rect(11f, 11f, 20f, 20f) + assertFalse(r1.intersects(r2)) + } + + @Test + fun testNeverBlocksNever() { + val detector = CollisionDetector() + val p1 = label("A", 0f, 0f, 10f, 10f, OverlapMode.Never) + val p2 = label("B", 5f, 5f, 10f, 10f, OverlapMode.Never) + + assertTrue(detector.tryPlaceLabel(p1)) + assertFalse(detector.tryPlaceLabel(p2)) // collides with p1 + } + + @Test + fun testAlwaysIsPlacedAndBlocksNever() { + val detector = CollisionDetector() + val p1 = label("A", 0f, 0f, 10f, 10f, OverlapMode.Always) + val p2 = label("B", 5f, 5f, 10f, 10f, OverlapMode.Never) + + assertTrue(detector.tryPlaceLabel(p1)) // always placed + assertFalse(detector.tryPlaceLabel(p2)) // p1 (Always) blocks p2 (Never) + } + + @Test + fun testAlwaysDoesNotBlockAlways() { + val detector = CollisionDetector() + val p1 = label("A", 0f, 0f, 10f, 10f, OverlapMode.Always) + val p2 = label("B", 5f, 5f, 10f, 10f, OverlapMode.Always) + + assertTrue(detector.tryPlaceLabel(p1)) + assertTrue(detector.tryPlaceLabel(p2)) // Always is never blocked + } + + @Test + fun testCooperativeDoesNotBlockCooperative() { + val detector = CollisionDetector() + val p1 = label("A", 0f, 0f, 10f, 10f, OverlapMode.Cooperative) + val p2 = label("B", 5f, 5f, 10f, 10f, OverlapMode.Cooperative) + + assertTrue(detector.tryPlaceLabel(p1)) + assertTrue(detector.tryPlaceLabel(p2)) // two Cooperative symbols can overlap + } + + @Test + fun testNeverBlocksCooperative() { + val detector = CollisionDetector() + val p1 = label("A", 0f, 0f, 10f, 10f, OverlapMode.Never) + val p2 = label("B", 5f, 5f, 10f, 10f, OverlapMode.Cooperative) + + assertTrue(detector.tryPlaceLabel(p1)) + assertFalse(detector.tryPlaceLabel(p2)) // Never blocks Cooperative + } + + @Test + fun testCooperativeBlocksNever() { + val detector = CollisionDetector() + val p1 = label("A", 0f, 0f, 10f, 10f, OverlapMode.Cooperative) + val p2 = label("B", 5f, 5f, 10f, 10f, OverlapMode.Never) + + assertTrue(detector.tryPlaceLabel(p1)) + assertFalse(detector.tryPlaceLabel(p2)) // Cooperative is in tree → blocks Never + } + + @Test + fun testIgnorePlacementDoesNotBlockOthers() { + val detector = CollisionDetector() + val p1 = label("A", 0f, 0f, 10f, 10f, ignorePlacement = true) + val p2 = label("B", 5f, 5f, 10f, 10f, OverlapMode.Never) + + assertTrue(detector.tryPlaceLabel(p1)) // ignore-placement is always placed + assertTrue(detector.tryPlaceLabel(p2)) // p1 not in tree → doesn't block p2 + } + + @Test + fun testNonOverlappingNeverSymbolsAreAllPlaced() { + val detector = CollisionDetector() + val p1 = label("A", 0f, 0f, 10f, 10f, OverlapMode.Never) + val p2 = label("B", 20f, 0f, 10f, 10f, OverlapMode.Never) // no overlap + + assertTrue(detector.tryPlaceLabel(p1)) + assertTrue(detector.tryPlaceLabel(p2)) + } +} diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/LineLabelPlacementTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/LineLabelPlacementTest.kt new file mode 100644 index 00000000..c54bf782 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/LineLabelPlacementTest.kt @@ -0,0 +1,53 @@ +package ovh.plrapps.mapcompose.vector.renderer.collision + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LineLabelPlacementTest { + @Test + fun testSingleSegmentOneLabel() { + val points = listOf(0f to 0f, 100f to 0f) + val textWidth = 40f + val spacing = 100f + val placements = LineLabelPlacement.calculatePlacements(points, textWidth, spacing) + assertEquals(1, placements.size) + val (pos, angle) = placements[0] + assertTrue(pos.first > 0 && pos.first < 100) + assertEquals(0f, angle) + } + + @Test + fun testLongSegmentMultipleLabels() { + val points = listOf(0f to 0f, 300f to 0f) + val textWidth = 40f + val spacing = 100f + val placements = LineLabelPlacement.calculatePlacements(points, textWidth, spacing) + assertTrue(placements.size > 1) + for (i in 1 until placements.size) { + val prev = placements[i-1].first.first + val curr = placements[i].first.first + assertTrue(curr > prev) + } + } + + @Test + fun testVerticalSegmentAngle() { + val points = listOf(0f to 0f, 0f to 100f) + val textWidth = 20f + val spacing = 50f + val placements = LineLabelPlacement.calculatePlacements(points, textWidth, spacing) + assertTrue(placements.isNotEmpty()) + val angle = placements[0].second + assertTrue(angle == 90f || angle == -90f) + } + + @Test + fun testNoPlacementIfTooShort() { + val points = listOf(0f to 0f, 10f to 0f) + val textWidth = 20f + val spacing = 100f + val placements = LineLabelPlacement.calculatePlacements(points, textWidth, spacing) + assertTrue(placements.isEmpty()) + } +} \ No newline at end of file diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/ColorParserTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/ColorParserTest.kt new file mode 100644 index 00000000..d52a2f24 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/ColorParserTest.kt @@ -0,0 +1,108 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +import androidx.compose.ui.graphics.Color +import ovh.plrapps.mapcompose.vector.spec.style.utils.ColorParser +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertNotNull + +class ColorParserTest { + private val parser = ColorParser + + @Test + fun `parse should handle hex colors`() { + assertEquals(Color(0xFF, 0x00, 0x00), parser.parseColorStringOrNull("#F00")) + assertEquals(Color(0x00, 0xFF, 0x00), parser.parseColorStringOrNull("#0F0")) + assertEquals(Color(0x00, 0x00, 0xFF), parser.parseColorStringOrNull("#00F")) + + assertEquals(Color(0xFF, 0x00, 0x00), parser.parseColorStringOrNull("#FF0000")) + assertEquals(Color(0x00, 0xFF, 0x00), parser.parseColorStringOrNull("#00FF00")) + assertEquals(Color(0x00, 0x00, 0xFF), parser.parseColorStringOrNull("#0000FF")) + + assertEquals(Color(0xFF, 0x00, 0x00, 0x80), parser.parseColorStringOrNull("#FF000080")) + assertEquals(Color(0x00, 0xFF, 0x00, 0x80), parser.parseColorStringOrNull("#00FF0080")) + assertEquals(Color(0x00, 0x00, 0xFF, 0x80), parser.parseColorStringOrNull("#0000FF80")) + + assertNull(parser.parseColorStringOrNull("#")) + assertNull(parser.parseColorStringOrNull("#12345")) + assertNull(parser.parseColorStringOrNull("#GGGGGG")) + } + + @Test + fun `parse should handle rgb colors`() { + assertEquals(Color(255, 0, 0), parser.parseColorStringOrNull("rgb(255, 0, 0)")) + assertEquals(Color(0, 255, 0), parser.parseColorStringOrNull("rgb(0, 255, 0)")) + assertEquals(Color(0, 0, 255), parser.parseColorStringOrNull("rgb(0, 0, 255)")) + assertEquals(Color(0xFF6495ED), parser.parseColorStringOrNull("cornflowerblue")) + assertEquals(Color(0xFF000000), parser.parseColorStringOrNull("black")) + assertEquals(Color(0xFFB22222), parser.parseColorStringOrNull("firebrick")) + + assertNull(parser.parseColorStringOrNull("rgb()")) + assertNull(parser.parseColorStringOrNull("rgb(255)")) + assertNull(parser.parseColorStringOrNull("rgb(255, 0)")) + // CSS Color 4 allows an alpha argument on rgb(); the old hand-rolled parser did not. + assertEquals(Color(255, 0, 0, 0), parser.parseColorStringOrNull("rgb(255, 0, 0, 0)")) + } + + @Test + fun `parse should handle rgba colors`() { + assertEquals(Color(255, 0, 0, 128), parser.parseColorStringOrNull("rgba(255, 0, 0, 0.5)")) + assertEquals(Color(0, 255, 0, 128), parser.parseColorStringOrNull("rgba(0, 255, 0, 0.5)")) + assertEquals(Color(0, 0, 255, 128), parser.parseColorStringOrNull("rgba(0, 0, 255, 0.5)")) + + // wrong RGBA colors + assertNull(parser.parseColorStringOrNull("rgba()")) + assertNull(parser.parseColorStringOrNull("rgba(255)")) + assertNull(parser.parseColorStringOrNull("rgba(255, 0)")) + // rgba() has the same grammar as rgb() in CSS Color 4, so alpha may be omitted. + assertEquals(Color(255, 0, 0), parser.parseColorStringOrNull("rgba(255, 0, 0)")) + } + + @Test + fun `parse should handle hsl colors`() { + assertEquals(Color(255, 0, 0), parser.parseColorStringOrNull("hsl(0, 100%, 50%)")) + assertEquals(Color(0, 255, 0), parser.parseColorStringOrNull("hsl(120, 100%, 50%)")) + assertEquals(Color(0, 0, 255), parser.parseColorStringOrNull("hsl(240, 100%, 50%)")) + + assertNull(parser.parseColorStringOrNull("hsl()")) + assertNull(parser.parseColorStringOrNull("hsl(0)")) + assertNull(parser.parseColorStringOrNull("hsl(0, 100%)")) + // CSS Color 4 allows an alpha argument on hsl(). + assertEquals(Color(255, 0, 0, 128), parser.parseColorStringOrNull("hsl(0, 100%, 50%, 0.5)")) + } + + @Test + fun `parse should handle hsla colors`() { + assertEquals(Color(255, 0, 0, 128), parser.parseColorStringOrNull("hsla(0, 100%, 50%, 0.5)")) + assertEquals(Color(0, 255, 0, 128), parser.parseColorStringOrNull("hsla(120, 100%, 50%, 0.5)")) + assertEquals(Color(0, 0, 255, 128), parser.parseColorStringOrNull("hsla(240, 100%, 50%, 0.5)")) + + assertNull(parser.parseColorStringOrNull("hsla()")) + assertNull(parser.parseColorStringOrNull("hsla(0)")) + assertNull(parser.parseColorStringOrNull("hsla(0, 100%)")) + // hsla() has the same grammar as hsl() in CSS Color 4, so alpha may be omitted. + assertEquals(Color(255, 0, 0), parser.parseColorStringOrNull("hsla(0, 100%, 50%)")) + } + + @Test + fun `parse should handle null and invalid input`() { + assertNull(parser.parseColorStringOrNull("")) + assertNull(parser.parseColorStringOrNull("invalid")) + assertNull(parser.parseColorStringOrNull("color(255, 0, 0)")) + } + + @Test + fun `parse should handle hsl color`() { + val color = ColorParser.parseColorStringOrNull("hsl(75,51%,85%)") + assertNotNull(color) + assertEquals(Color.hsl(hue = 75F, saturation = 0.51f, lightness = 0.85f), color) + } + + @Test + fun `parse should handle hsla color`() { + val color = ColorParser.parseColorStringOrNull("hsla(9, 100%, 64%, 0.4)") + assertNotNull(color) + assertEquals(Color.hsl(hue = 9F, saturation = 1f, lightness = 0.64f, alpha = 0.4f), color) + } +} \ No newline at end of file diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/LayoutTypeTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/LayoutTypeTest.kt new file mode 100644 index 00000000..d30cf5dc --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/LayoutTypeTest.kt @@ -0,0 +1,28 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +import kotlin.test.Test +import kotlin.test.assertEquals + +class LayoutTypeTest { + @Test + fun `parse should handle different cases`() { + assertEquals(LayoutType.FILL, LayoutType.parse("fill")) + assertEquals(LayoutType.FILL, LayoutType.parse("FILL")) + assertEquals(LayoutType.FILL, LayoutType.parse("Fill")) + assertEquals(LayoutType.FILL, LayoutType.parse("fIlL")) + } + + @Test + fun `parse should handle hyphenated types`() { + assertEquals(LayoutType.FILL_EXTRUSION, LayoutType.parse("fill-extrusion")) + assertEquals(LayoutType.FILL_EXTRUSION, LayoutType.parse("FILL-EXTRUSION")) + assertEquals(LayoutType.FILL_EXTRUSION, LayoutType.parse("Fill-Extrusion")) + } + + @Test + fun `parse should return UNDEFINED for invalid input`() { + assertEquals(LayoutType.UNDEFINED, LayoutType.parse("invalid")) + assertEquals(LayoutType.UNDEFINED, LayoutType.parse("")) + assertEquals(LayoutType.UNDEFINED, LayoutType.parse(" ")) + } +} \ No newline at end of file diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleBright.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleBright.kt new file mode 100644 index 00000000..15c8e422 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleBright.kt @@ -0,0 +1,131 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsColor +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsDouble + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import org.jetbrains.compose.resources.ExperimentalResourceApi +import mapcompose_mp.library.generated.resources.Res +import ovh.plrapps.mapcompose.vector.data.MapLibreConfiguration +import ovh.plrapps.mapcompose.vector.data.getMapLibreConfiguration +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class TestParseStyleBright { + + @OptIn(ExperimentalResourceApi::class) + @Test + fun `style_bright correct parsed`() = runComposeUiTest { + var simpleStyle: MapLibreConfiguration? = null + val loadResource: suspend (String) -> RawSource? = { url -> + when { + url.endsWith(".json") -> { + val resource = Res.readBytes("files/test_style_bright_sprite.json") + val buffer = Buffer() + buffer.write(resource) + buffer + } + url.endsWith(".png") -> { + val resource = Res.readBytes("files/test_style_bright_sprite.png") + val buffer = Buffer() + buffer.write(resource) + buffer + } + else -> null + } + } + + setContent { + val style by produceState(null) { + value = Res.readBytes("files/test_style_bright.json").decodeToString().let { source -> + getMapLibreConfiguration(style = source, loadResource = loadResource).getOrThrow() + } + } + simpleStyle = style + } + + waitUntil(timeoutMillis = 5000) { + simpleStyle != null + } + + val style = simpleStyle!!.style + + + assertEquals(8, style.version) + assertEquals("Bright", style.name) + assertEquals(listOf(0.0, 0.0), style.center) + assertEquals(1f, style.zoom) + assertEquals(0.0, style.bearing) + assertEquals(0.0, style.pitch) + + + val sources = style.sources + assertNotNull(sources) + assertEquals(1, sources.size) + + val openmaptiles = sources["openmaptiles"] + assertNotNull(openmaptiles) + assertEquals("vector", openmaptiles.type) + assertEquals(0, openmaptiles.minzoom) + assertEquals(14, openmaptiles.maxzoom) + assertNotNull(openmaptiles.tiles) + assertTrue(openmaptiles.tiles.isNotEmpty()) + assertNotNull(openmaptiles.attribution) + assertTrue(openmaptiles.attribution.contains("MapTiler")) + assertTrue(openmaptiles.attribution.contains("OpenStreetMap")) + + assertEquals("https://openmaptiles.github.io/osm-bright-gl-style/sprite", style.sprites.firstOrNull()?.url) + assertEquals("https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key={key}", style.glyphs) + + + val layers = style.layers + assertNotNull(layers) + assertTrue(layers.isNotEmpty()) + + val backgroundLayer = layers.find { it.id == "background" } as BackgroundLayer + assertNotNull(backgroundLayer) + assertEquals("background", backgroundLayer.type) + val bgColor = backgroundLayer.paint?.backgroundColor?.process() + assertEquals(Color(0xFFF8F4F0), bgColor) + + val landuseLayer = layers.find { it.id == "landuse-residential" } as FillLayer + assertNotNull(landuseLayer) + assertEquals("fill", landuseLayer.type) + assertEquals("openmaptiles", landuseLayer.source) + assertEquals("landuse", landuseLayer.sourceLayer) + + val landuseColor = landuseLayer.paint?.fillColor + assertNotNull(landuseColor) + assertTrue(landuseColor is ExpressionOrValue.Expression) + // A zoom-driven interpolate: assert the values it produces at its own stops. + assertEquals(listOf(12.0, 16.0), landuseColor.expression.zoomStops) + assertEquals(Color.hsl(30f, 0.19f, 0.9f, 0.4f), landuseColor.processAsColor(zoom = 12.0)) + assertEquals(Color.hsl(30f, 0.19f, 0.9f, 0.2f), landuseColor.processAsColor(zoom = 16.0)) + + val waterwayLayer = layers.find { it.id == "waterway_tunnel" } as LineLayer + assertNotNull(waterwayLayer) + assertEquals("line", waterwayLayer.type) + assertEquals("openmaptiles", waterwayLayer.source) + assertEquals("waterway", waterwayLayer.sourceLayer) + + val lineWidth = waterwayLayer.paint?.lineWidth + assertNotNull(lineWidth) + assertTrue(lineWidth is ExpressionOrValue.Expression) + assertEquals(listOf(13.0, 20.0), lineWidth.expression.zoomStops) + assertEquals(0.5, lineWidth.processAsDouble(zoom = 13.0)) + assertEquals(6.0, lineWidth.processAsDouble(zoom = 20.0)) + + val lineColor = waterwayLayer.paint.lineColor?.process() + assertEquals(Color(0xFFA0C8F0), lineColor) + } +} diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleSimple.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleSimple.kt new file mode 100644 index 00000000..6dcce8bd --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleSimple.kt @@ -0,0 +1,120 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsColor +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsDouble +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsString + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import ovh.plrapps.mapcompose.vector.data.MapLibreConfiguration +import ovh.plrapps.mapcompose.vector.data.getMapLibreConfiguration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import androidx.compose.ui.graphics.Color +import kotlinx.io.RawSource +import org.jetbrains.compose.resources.ExperimentalResourceApi +import mapcompose_mp.library.generated.resources.Res +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue + +@OptIn(ExperimentalTestApi::class) +class TestParseStyleSimple { + + @OptIn(ExperimentalResourceApi::class) + @Test + fun `style_simple correct parsed`() = runComposeUiTest { + var simpleStyle: MapLibreConfiguration? = null + val loadResource: suspend (String) -> RawSource? = { null } + + setContent { + val style by produceState(null) { + value = Res.readBytes("files/test_style_simple.json").decodeToString().let { source -> + getMapLibreConfiguration(style = source, loadResource = loadResource).getOrThrow() + } + } + simpleStyle = style + } + + waitUntil(timeoutMillis = 5000) { + simpleStyle != null + } + + val style = simpleStyle!!.style + + assertEquals(8, style.version) + assertEquals("MapLibre", style.name) + assertEquals(listOf(17.65431710431244, 32.954120326746775), style.center) + assertEquals(0f, style.zoom) + assertEquals(0.0, style.bearing) + assertEquals(0.0, style.pitch) + + val sources = style.sources + assertNotNull(sources) + assertEquals(1, sources.size) + val maplibreSource = sources["maplibre"] + assertNotNull(maplibreSource) + assertEquals("vector", maplibreSource.type) + + val layers = style.layers + assertNotNull(layers) + assertTrue(layers.isNotEmpty()) + + val backgroundLayer = layers.find { it.id == "background" } as BackgroundLayer + assertNotNull(backgroundLayer) + assertEquals("background", backgroundLayer.type) + val bgColor = backgroundLayer.paint?.backgroundColor?.process() + println("backgroundLayer.paint?.backgroundColor?.process() = $bgColor") + assertEquals(Color(0xFFD8F2FF), bgColor) + + val coastlineLayer = layers.find { it.id == "coastline" } as LineLayer + assertNotNull(coastlineLayer) + assertEquals("line", coastlineLayer.type) + val lineWidth = coastlineLayer.paint?.lineWidth + assertNotNull(lineWidth) + assertTrue(lineWidth is ExpressionOrValue.Expression) + assertEquals(listOf(0.0, 6.0, 14.0, 22.0), lineWidth.expression.zoomStops) + assertEquals(2.0, lineWidth.processAsDouble(zoom = 0.0)) + assertEquals(6.0, lineWidth.processAsDouble(zoom = 6.0)) + assertEquals(9.0, lineWidth.processAsDouble(zoom = 14.0)) + assertEquals(18.0, lineWidth.processAsDouble(zoom = 22.0)) + val coastColor = coastlineLayer.paint.lineColor?.process() + println("coastlineLayer.paint?.lineColor?.process() = $coastColor") + assertEquals(Color(0xFF198EC8), coastColor) + assertEquals(0.5, coastlineLayer.paint.lineBlur?.process()) + + val countriesFillLayer = layers.find { it.id == "countries-fill" } as FillLayer + assertNotNull(countriesFillLayer) + val fillColor = countriesFillLayer.paint?.fillColor + assertNotNull(fillColor) + assertTrue(fillColor is ExpressionOrValue.Expression) + // A data-driven ["match", ["get", "ADM0_A3"], ...]: assert what it resolves to. + assertEquals( + Color(0xFFD6C7FF), + fillColor.processAsColor(EvalFeature(type = "Polygon", properties = mapOf("ADM0_A3" to "ARM"))), + ) + assertEquals( + Color(0xFFEAB38F), + fillColor.processAsColor(EvalFeature(type = "Polygon", properties = mapOf("ADM0_A3" to "ZZZ"))), + ) + + val geolinesLayer = layers.find { it.id == "geolines" } as LineLayer + assertNotNull(geolinesLayer) + + val countriesLabelLayer = layers.find { it.id == "countries-label" } as SymbolLayer + println("countriesLabelLayer = $countriesLabelLayer") + assertNotNull(countriesLabelLayer) + val textField = countriesLabelLayer.layout?.textField + println("textField = $textField") + assertNotNull(textField) + assertTrue(textField is ExpressionOrValue.Expression) + // A legacy {stops} function on a string property becomes a `step`, not an `interpolate`. + assertEquals("{ABBREV}", textField.processAsString(zoom = 2.0)) + assertEquals("{ABBREV}", textField.processAsString(zoom = 3.0)) + assertEquals("{NAME}", textField.processAsString(zoom = 4.0)) + assertEquals("{NAME}", textField.processAsString(zoom = 5.0)) + } +} diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleStreetV2.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleStreetV2.kt new file mode 100644 index 00000000..7726d6ee --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleStreetV2.kt @@ -0,0 +1,129 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import ovh.plrapps.mapcompose.vector.data.MapLibreConfiguration +import ovh.plrapps.mapcompose.vector.data.getMapLibreConfiguration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import androidx.compose.ui.graphics.Color +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import org.jetbrains.compose.resources.ExperimentalResourceApi +import mapcompose_mp.library.generated.resources.Res +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsColor +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsDouble +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue + +@OptIn(ExperimentalTestApi::class) +class TestParseStyleStreetV2 { + + @OptIn(ExperimentalResourceApi::class) + @Test + fun `style_streetV2 correct parsed`() = runComposeUiTest { + var styleStreetV2: MapLibreConfiguration? = null + val loadResource: suspend (String) -> RawSource? = { url -> + when { + url.endsWith(".json") -> { + val resource = Res.readBytes("files/test_style_street_v2_sprite.json") + val buffer = Buffer() + buffer.write(resource) + buffer + } + url.endsWith(".png") -> { + val resource = Res.readBytes("files/test_style_street_v2_sprite.png") + val buffer = Buffer() + buffer.write(resource) + buffer + } + else -> null + } + } + + setContent { + val style by produceState(null) { + value = Res.readBytes("files/test_style_street_v2.json").decodeToString().let { source -> + getMapLibreConfiguration(style = source, loadResource = loadResource).getOrThrow() + } + } + styleStreetV2 = style + } + + waitUntil(timeoutMillis = 5000) { + styleStreetV2 != null + } + + val style = styleStreetV2!!.style + + assertEquals(8, style.version) + assertEquals("streets-v2", style.id) + assertEquals("Streets", style.name) + + val sources = style.sources + assertNotNull(sources) + assertEquals(2, sources.size) + + val maptilerAttribution = sources["maptiler_attribution"] + assertNotNull(maptilerAttribution) + assertEquals("vector", maptilerAttribution.type) + assertTrue(maptilerAttribution.attribution?.contains("MapTiler") == true) + + val maptilerPlanet = sources["maptiler_planet"] + assertNotNull(maptilerPlanet) + assertEquals("vector", maptilerPlanet.type) + assertEquals(0, maptilerPlanet.minzoom) + assertEquals(15, maptilerPlanet.maxzoom) + assertTrue(maptilerPlanet.tiles?.first()?.contains("api.maptiler.com") == true) + + val layers = style.layers + assertNotNull(layers) + assertTrue(layers.isNotEmpty()) + + val backgroundLayer = layers.find { it.id == "Background" } as BackgroundLayer + assertNotNull(backgroundLayer) + assertEquals("background", backgroundLayer.type) + val bgColor = backgroundLayer.paint?.backgroundColor + assertNotNull(bgColor) + assertTrue(bgColor is ExpressionOrValue.Expression) + val bgColorExpr = bgColor + val bgStops = bgColorExpr.expression.zoomStops!! + assertEquals(2, bgStops.size) + assertEquals(6.0, bgStops[0]) + assertEquals(14.0, bgStops[1]) + assertEquals(Color.hsl(47F, 0.79F, 0.94F), bgColorExpr.processAsColor(zoom = 6.0)) + assertEquals(Color.hsl(42F, 0.49f, 0.93f), bgColorExpr.processAsColor(zoom = 14.0)) + + val meadowLayer = layers.find { it.id == "Meadow" } as FillLayer + assertNotNull(meadowLayer) + assertEquals("fill", meadowLayer.type) + assertEquals("maptiler_planet", meadowLayer.source) + assertEquals("globallandcover", meadowLayer.sourceLayer) + assertEquals(8.toDouble(), meadowLayer.maxzoom) + val fillColor = meadowLayer.paint?.fillColor + assertNotNull(fillColor) + assertTrue(fillColor is ExpressionOrValue.Value) + assertEquals(Color.hsl(75f,0.51f,0.85f), fillColor.value) + val meadowColor = fillColor.process(null, null) + assertEquals(Color.hsl(hue = 75F, saturation = 0.51f, lightness = 0.85f), meadowColor) + val meadowOpacity = meadowLayer.paint.fillOpacity + assertNotNull(meadowOpacity) + assertTrue(meadowOpacity is ExpressionOrValue.Expression) + val meadowOpacityExpr = meadowOpacity as ExpressionOrValue.Expression<*> + val meadowOpacityStops = meadowOpacityExpr.expression.zoomStops!! + assertEquals(2, meadowOpacityStops.size) + assertEquals(0.0, meadowOpacityStops[0]) + assertEquals(8.0, meadowOpacityStops[1]) + assertEquals(1.0, meadowOpacity.processAsDouble(zoom = 0.0)) + assertEquals(0.1, meadowOpacity.processAsDouble(zoom = 8.0)) + + val forestLayer = layers.find { it.id == "Forest" } as FillLayer + assertNotNull(forestLayer) + assertEquals("fill", forestLayer.type) + val forestFilter = forestLayer.filter + assertNotNull(forestFilter) + } +} diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleSwisstopo.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleSwisstopo.kt new file mode 100644 index 00000000..18b448d5 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleSwisstopo.kt @@ -0,0 +1,42 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature +import ovh.plrapps.mapcompose.vector.spec.style.expression.GlobalProperties + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertTrue +import org.jetbrains.compose.resources.ExperimentalResourceApi +import mapcompose_mp.library.generated.resources.Res +import ovh.plrapps.mapcompose.vector.data.json + +@OptIn(ExperimentalTestApi::class) +class TestParseStyleSwisstopo { + + @OptIn(ExperimentalResourceApi::class) + @Test + fun `style_swisstopo parses without error`() = runComposeUiTest { + var style: MapLibreStyle? = null + + setContent { + val result by produceState(null) { + val content = Res.readBytes("files/test_style_swisstopo.json").decodeToString() + value = json.decodeFromString(content) + } + style = result + } + + waitUntil(timeoutMillis = 5000) { style != null } + + val layers = style!!.layers + assertTrue(layers.isNotEmpty()) + + // Exercise every filter: verifies both deserialization and evaluation don't throw + for (layer in layers) { + layer.filter?.filter?.filter(GlobalProperties(zoom = 10.0), EvalFeature(type = "LineString")) + } + } +} diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/ExpressionConformanceTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/ExpressionConformanceTest.kt new file mode 100644 index 00000000..1187225e --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/ExpressionConformanceTest.kt @@ -0,0 +1,505 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import mapcompose_mp.library.generated.resources.Res +import org.jetbrains.compose.resources.ExperimentalResourceApi +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.Formatted +import ovh.plrapps.mapcompose.vector.spec.style.expression.types.ResolvedImage +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.EXTENT +import ovh.plrapps.mapcompose.vector.spec.style.expression.geometry.getTileCoordinates +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.pow +import kotlin.test.Test +import kotlin.test.fail + +/** + * Runs MapLibre's own expression conformance suite against this engine. + * + * The fixtures are vendored verbatim from `maplibre-style-spec/test/integration/expression/tests` + * (577 cases across 89 operators), flattened into one bundled resource by + * `library/tools/fetch-expression-fixtures.sh`. Each fixture supplies an expression, a list of + * `[globals, feature]` inputs, the expected compilation result, and the expected output per input. + * + * What is asserted: the compile result (`success`/`error`), the inferred output type, the + * `isFeatureConstant` / `isZoomConstant` static analysis, and every evaluated output. Upstream error + * *messages* are not compared — only that an error occurred — because they embed JavaScript type + * names and JSON formatting that this port has no reason to reproduce exactly. + * + * [KNOWN_DIVERGENCES] lists the cases this port deliberately does not satisfy, each with a reason. + */ +@OptIn(ExperimentalTestApi::class) +class ExpressionConformanceTest { + + @OptIn(ExperimentalResourceApi::class) + @Test + fun `maplibre expression conformance suite`() = runComposeUiTest { + var bundle: JsonObject? = null + + setContent { + val loaded by produceState(null) { + val text = Res.readBytes("files/expression-tests.json").decodeToString() + value = json.parseToJsonElement(text).jsonObject + } + bundle = loaded + } + + waitUntil(timeoutMillis = 30_000) { bundle != null } + + val tests = bundle!!["tests"]!!.jsonObject + val failures = mutableListOf() + var ran = 0 + var skipped = 0 + + for ((name, fixtureElement) in tests) { + if (KNOWN_DIVERGENCES.containsKey(name)) { + skipped++ + continue + } + val fixture = fixtureElement.jsonObject + val result = try { + runFixture(name, fixture) + } catch (e: Throwable) { + // A throw out of the parser is itself a conformance failure: upstream collects + // parse errors rather than throwing. + FixtureResult.Failed("threw ${e::class.simpleName}: ${e.message}") + } + when (result) { + is FixtureResult.Skipped -> skipped++ + is FixtureResult.Passed -> ran++ + is FixtureResult.Failed -> { + ran++ + failures.add("$name: ${result.reason}") + } + } + } + + if (failures.isNotEmpty()) { + fail( + "${failures.size} of $ran conformance fixtures failed ($skipped skipped):\n" + + failures.take(40).joinToString("\n") + + if (failures.size > 40) "\n… and ${failures.size - 40} more" else "" + ) + } + } + + private sealed class FixtureResult { + data object Passed : FixtureResult() + data object Skipped : FixtureResult() + data class Failed(val reason: String) : FixtureResult() + } + + private fun runFixture(name: String, fixture: JsonObject): FixtureResult { + val expression = jsonToValue(fixture["expression"] ?: return FixtureResult.Skipped) + val expected = fixture["expected"]?.jsonObject ?: return FixtureResult.Skipped + val compiledExpected = expected["compiled"]?.jsonObject ?: return FixtureResult.Skipped + + // Upstream's harness completes a partial propertySpec (data-driven, interpolated, zoom + + // feature parameters) and always compiles through createPropertyExpression, so the static + // analysis is exercised on every fixture. + val spec = parsePropertySpec(fixture["propertySpec"]?.jsonObject) ?: return FixtureResult.Skipped + + val globalState = fixture["globalState"]?.jsonObject + ?.mapValues { (_, v) -> jsonToValue(v) } + + val expectedResult = compiledExpected["result"]?.jsonPrimitive?.content + + // A bare object is a legacy v7 function; upstream's harness converts it before compiling. + @Suppress("UNCHECKED_CAST") + val toCompile = if (expression is Map<*, *>) { + convertLegacyFunction(expression as Map, spec) + } else { + expression + } + + val propertyExpression = when ( + val compiled = createPropertyExpression(toCompile, ROOT_KEY, spec, globalState) + ) { + is ExpressionResult.Error -> + return if (expectedResult == "error") FixtureResult.Passed + else FixtureResult.Failed("expected success, got errors ${compiled.errors}") + + is ExpressionResult.Success -> { + if (expectedResult == "error") { + return FixtureResult.Failed("expected compile error, but it compiled") + } + compiled.value + } + } + val styleExpression = propertyExpression.styleExpression + + val expectedType = compiledExpected["type"]?.jsonPrimitive?.content + if (expectedType != null) { + val actualType = typeToString(styleExpression.expression.type) + if (actualType != expectedType) { + return FixtureResult.Failed("type: expected $expectedType, got $actualType") + } + } + + val kind = propertyExpression.kind + val expectedFeatureConstant = compiledExpected["isFeatureConstant"]?.jsonPrimitive?.booleanOrNull + if (expectedFeatureConstant != null) { + val actual = kind == EvaluationKind.CONSTANT || kind == EvaluationKind.CAMERA + if (actual != expectedFeatureConstant) { + return FixtureResult.Failed("isFeatureConstant: expected $expectedFeatureConstant, got $actual") + } + } + + val expectedZoomConstant = compiledExpected["isZoomConstant"]?.jsonPrimitive?.booleanOrNull + if (expectedZoomConstant != null) { + val actual = kind == EvaluationKind.CONSTANT || kind == EvaluationKind.SOURCE + if (actual != expectedZoomConstant) { + return FixtureResult.Failed("isZoomConstant: expected $expectedZoomConstant, got $actual") + } + } + + val inputs = fixture["inputs"]?.jsonArray ?: return FixtureResult.Passed + val outputs = expected["outputs"]?.jsonArray ?: return FixtureResult.Passed + + for (i in inputs.indices) { + val input = inputs[i].jsonArray + val globals = parseGlobals(input.getOrNull(0)) + val canonical = parseCanonical(input.getOrNull(0)) + val featureJson = input.getOrNull(1)?.jsonObject + val feature = parseFeature(featureJson, canonical) + val featureState = featureJson?.get("featureState")?.jsonObject + ?.mapValues { (_, v) -> jsonToValue(v) } + val availableImages = (input.getOrNull(0) as? JsonObject) + ?.get("availableImages")?.jsonArray?.map { it.jsonPrimitive.content } + + val expectedOutput = outputs.getOrNull(i) ?: continue + val expectsError = expectedOutput is JsonObject && expectedOutput.containsKey("error") + + val actual = try { + styleExpression.evaluateWithoutErrorHandling( + globals = globals, + feature = feature, + featureState = featureState, + canonical = canonical, + availableImages = availableImages, + ) + } catch (e: Exception) { + if (expectsError) continue + return FixtureResult.Failed("input[$i]: unexpected error ${e.message}") + } + + if (expectsError) { + return FixtureResult.Failed("input[$i]: expected an error, got ${describe(actual)}") + } + if (!valueMatches(expectedOutput, actual)) { + return FixtureResult.Failed("input[$i]: expected $expectedOutput, got ${describe(actual)}") + } + } + + return FixtureResult.Passed + } + + private fun parseGlobals(element: JsonElement?): GlobalProperties { + val obj = element as? JsonObject ?: return GlobalProperties(zoom = 0.0) + return GlobalProperties( + zoom = obj["zoom"]?.jsonPrimitive?.doubleOrNull ?: 0.0, + heatmapDensity = obj["heatmapDensity"]?.jsonPrimitive?.doubleOrNull, + elevation = obj["elevation"]?.jsonPrimitive?.doubleOrNull, + lineProgress = obj["lineProgress"]?.jsonPrimitive?.doubleOrNull, + accumulated = obj["accumulated"]?.let { jsonToValue(it) }, + ) + } + + private fun parseCanonical(element: JsonElement?): CanonicalTileId? { + val obj = (element as? JsonObject)?.get("canonicalID") as? JsonObject ?: return null + return CanonicalTileId( + z = obj["z"]?.jsonPrimitive?.doubleOrNull?.toInt() ?: 0, + x = obj["x"]?.jsonPrimitive?.doubleOrNull?.toInt() ?: 0, + y = obj["y"]?.jsonPrimitive?.doubleOrNull?.toInt() ?: 0, + ) + } + + /** + * Builds the feature, converting the fixture's GeoJSON geometry into tile-local coordinates. + * + * Ported from `test/lib/geometry.ts` in the upstream repo: the coordinates are projected with + * `getTileCoordinates` and then shifted to be relative to the tile, and `Multi*` geometries + * collapse to their singular type. + */ + private fun parseFeature(obj: JsonObject?, canonical: CanonicalTileId?): EvalFeature? { + if (obj == null) return null + val properties = (obj["properties"] as? JsonObject) + ?.mapValues { (_, v) -> jsonToValue(v) } ?: emptyMap() + + val geometryJson = obj["geometry"] as? JsonObject + val geometryType = geometryJson?.get("type")?.jsonPrimitive?.content + val type = when (geometryType) { + "MultiPoint" -> "Point" + "MultiLineString" -> "LineString" + "MultiPolygon" -> "Polygon" + null -> "Unknown" + else -> geometryType + } + + val rings: List>? = + if (geometryJson != null && canonical != null) { + val coordinates = geometryJson["coordinates"] + when (geometryType) { + "Point" -> listOf(listOf(tilePoint(coordinates!!.jsonArray, canonical))) + "MultiPoint" -> coordinates!!.jsonArray.map { listOf(tilePoint(it.jsonArray, canonical)) } + "LineString" -> listOf(tileLine(coordinates!!.jsonArray, canonical)) + "MultiLineString", "Polygon" -> coordinates!!.jsonArray.map { tileLine(it.jsonArray, canonical) } + "MultiPolygon" -> coordinates!!.jsonArray.flatMap { polygon -> + polygon.jsonArray.map { tileLine(it.jsonArray, canonical) } + } + + else -> null + } + } else { + null + } + + return EvalFeature( + type = type, + id = obj["id"]?.let { jsonToValue(it) }, + properties = properties, + geometryProvider = rings?.let { { it } }, + ) + } + + private fun tilePoint(position: JsonArray, canonical: CanonicalTileId): Point2D { + val coord = getTileCoordinates( + listOf(position[0].jsonPrimitive.doubleOrNull ?: 0.0, position[1].jsonPrimitive.doubleOrNull ?: 0.0), + canonical, + ) + // Shift so the point is relative to the tile rather than the world. + return Point2D(coord[0] - canonical.x.toDouble() * EXTENT, coord[1] - canonical.y.toDouble() * EXTENT) + } + + private fun tileLine(line: JsonArray, canonical: CanonicalTileId): List = + line.map { tilePoint(it.jsonArray, canonical) } + + /** Returns null for property types this port deliberately does not model. */ + private fun parsePropertySpec(spec: JsonObject?): StylePropertySpec? { + if (spec == null) { + // The harness's completed default: data-driven, interpolated, zoom + feature. + return StylePropertySpec(expectedType = null, supportsInterpolation = true) + } + val typeName = spec["type"]?.jsonPrimitive?.content + val expectedType: ExprType? = when (typeName) { + "number" -> NumberType + "string" -> StringType + "boolean" -> BooleanType + "color" -> ColorType + "formatted" -> FormattedType + "resolvedImage" -> ResolvedImageType + "null" -> NullType + "enum" -> StringType + "array" -> { + val itemType = when (spec["value"]?.jsonPrimitive?.content) { + "number" -> NumberType + "string" -> StringType + "boolean" -> BooleanType + else -> ValueType + } + array(itemType, spec["length"]?.jsonPrimitive?.doubleOrNull?.toInt()) + } + + null -> null + else -> return null // projectionDefinition, padding, colorArray, numberArray, … + } + + val expression = spec["expression"]?.jsonObject + val parameters = expression?.get("parameters")?.jsonArray?.map { it.jsonPrimitive.content } + ?: listOf("zoom", "feature") + + return StylePropertySpec( + expectedType = expectedType, + defaultValue = spec["default"]?.let { jsonToValue(it) }, + enumValues = (spec["values"] as? JsonObject)?.keys, + supportsPropertyExpression = "feature" in parameters, + supportsZoomExpression = "zoom" in parameters, + supportsInterpolation = expression?.get("interpolated")?.jsonPrimitive?.booleanOrNull ?: true, + tokens = spec["tokens"]?.jsonPrimitive?.booleanOrNull ?: false, + ) + } + + private fun describe(value: Any?): String = when (value) { + is Color -> "[${value.red}, ${value.green}, ${value.blue}, ${value.alpha}]" + else -> valueToJsonString(value) + } + + /** + * Structural comparison between a fixture's expected JSON and an engine value. + * + * Colors are compared against upstream's `[r, g, b, a]` (0..1, unpremultiplied) encoding, and + * numbers with a small tolerance because the fixtures were produced by a double-precision + * JavaScript engine. + */ + private fun valueMatches(expected: JsonElement, actual: Any?): Boolean = when { + expected is JsonNull -> actual == null + + // Upstream serializes a color as its premultiplied [r, g, b, a] components. + actual is Color -> expected is JsonArray && expected.size == 4 && + colorChannelMatches(expected[0], (actual.red * actual.alpha).toDouble()) && + colorChannelMatches(expected[1], (actual.green * actual.alpha).toDouble()) && + colorChannelMatches(expected[2], (actual.blue * actual.alpha).toDouble()) && + colorChannelMatches(expected[3], actual.alpha.toDouble()) + + actual is Formatted -> expected is JsonObject && formattedMatches(expected, actual) + + actual is ResolvedImage -> expected is JsonObject && + expected["name"]?.jsonPrimitive?.content == actual.name && + expected["available"]?.jsonPrimitive?.booleanOrNull == actual.available + + expected is JsonArray -> actual is List<*> && expected.size == actual.size && + expected.indices.all { valueMatches(expected[it], actual[it]) } + + expected is JsonObject -> actual is Map<*, *> && expected.keys == actual.keys && + expected.all { (k, v) -> valueMatches(v, actual[k]) } + + expected is JsonPrimitive && actual is Number -> numberMatches(expected, actual.toDouble()) + + expected is JsonPrimitive && actual is Boolean -> expected.booleanOrNull == actual + + expected is JsonPrimitive && actual is String -> expected.isString && expected.content == actual + + else -> false + } + + /** + * Colors are compared at 8-bit precision. + * + * `androidx.compose.ui.graphics.Color` stores sRGB channels as 8-bit values, while MapLibre + * keeps them as doubles, so an interpolated midpoint is 128/255 here and exactly 0.5 upstream. + * That is a storage difference in the host toolkit, not a difference in the expression engine, + * and it is invisible at the 8-bit render target. + */ + private fun colorChannelMatches(expected: JsonElement, actual: Double): Boolean { + val e = (expected as? JsonPrimitive)?.doubleOrNull ?: return false + return abs(e - actual) <= 1.0 / 255.0 + 1e-6 + } + + private fun formattedMatches(expected: JsonObject, actual: Formatted): Boolean { + val sections = expected["sections"]?.jsonArray ?: return false + if (sections.size != actual.sections.size) return false + return sections.indices.all { i -> + val e = sections[i].jsonObject + val a = actual.sections[i] + e["text"]?.jsonPrimitive?.content == a.text && + imageMatches(e["image"], a.image) && + nullableNumberMatches(e["scale"], a.scale) && + nullableStringMatches(e["fontStack"], a.fontStack) && + nullableColorMatches(e["textColor"], a.textColor) && + nullableStringMatches(e["verticalAlign"], a.verticalAlign?.value) + } + } + + private fun imageMatches(expected: JsonElement?, actual: ResolvedImage?): Boolean = when { + expected == null || expected is JsonNull -> actual == null + expected is JsonObject -> actual != null && + expected["name"]?.jsonPrimitive?.content == actual.name && + expected["available"]?.jsonPrimitive?.booleanOrNull == actual.available + + else -> false + } + + private fun nullableNumberMatches(expected: JsonElement?, actual: Double?): Boolean = when { + expected == null || expected is JsonNull -> actual == null + else -> actual != null && numberMatches(expected, actual) + } + + private fun nullableStringMatches(expected: JsonElement?, actual: String?): Boolean = when { + expected == null || expected is JsonNull -> actual == null + else -> (expected as? JsonPrimitive)?.content == actual + } + + private fun nullableColorMatches(expected: JsonElement?, actual: Color?): Boolean = when { + expected == null || expected is JsonNull -> actual == null + expected is JsonObject -> actual != null && + colorChannelMatches(expected["r"]!!, (actual.red * actual.alpha).toDouble()) && + colorChannelMatches(expected["g"]!!, (actual.green * actual.alpha).toDouble()) && + colorChannelMatches(expected["b"]!!, (actual.blue * actual.alpha).toDouble()) && + colorChannelMatches(expected["a"]!!, actual.alpha.toDouble()) + + else -> false + } + + private fun numberMatches(expected: JsonElement, actual: Double): Boolean { + val e = (expected as? JsonPrimitive)?.doubleOrNull ?: return false + if (e.isNaN() && actual.isNaN()) return true + return stripPrecision(e) == stripPrecision(actual) + } + + /** + * Truncates to 6 significant decimal figures, matching `stripPrecision` in + * `test/lib/json-diff.ts` — the fixtures' expected values were written out that way, so + * comparison has to happen in the same space. + */ + private fun stripPrecision(x: Double, decimalSigFigs: Int = 6): Double { + if (x == 0.0 || x.isNaN() || x.isInfinite()) return x + val multiplier = 10.0.pow(maxOf(0.0, decimalSigFigs - ceil(log10(abs(x))))) + // Stripped twice, as upstream does, to avoid re-stripping shifting the value. + val firstStrip = floor(x * multiplier) / multiplier + return floor(firstStrip * multiplier) / multiplier + } + + private companion object { + const val ROOT_KEY = "layers[0].paint.some-property" + + val json = Json { ignoreUnknownKeys = true; isLenient = true } + + /** + * Fixtures this port deliberately does not satisfy, each with its reason. + * + * Three root causes, none of them in the expression engine itself: + * + * 1. **No ICU on Kotlin Multiplatform.** `Intl.Collator` and `Intl.NumberFormat` have no + * equivalent, so locale-tailored collation (German "ü" == "ue", Swedish "ä" after "z") + * and locale-aware number formatting (grouping separators, currency symbols, CLDR unit + * names) cannot be reproduced. `Collator` implements ICU-style three-level comparison + * and `NumberFormat` honours the fraction-digit options, which is what the other 16 + * collator and number-format fixtures exercise. + * 2. **`androidx.compose.ui.graphics.Color` stores sRGB channels as 8 bits.** MapLibre + * keeps them as doubles. Round-tripping a channel therefore yields 128/255 where + * upstream yields exactly 0.5. Invisible at the 8-bit render target; the color + * comparisons elsewhere in this suite allow ±1/255 for the same reason. + * 3. **Property types MapCompose does not model** — `projectionDefinition`, `padding`, + * `numberArray`, `colorArray` and `variableAnchorOffsetCollection`. These are skipped + * by `parsePropertySpec` returning null rather than listed individually here; see the + * note on `ExprType`. + */ + val KNOWN_DIVERGENCES: Map = mapOf( + "collator/accent-equals-de" to + "German collation expands 'ü' to 'ue'; that is ICU locale tailoring.", + "collator/variable-gt" to + "Ordering of 'ä' against 'a' is locale-tailored (de/sv differ from dk/fr).", + "collator/variable-lteq" to + "Ordering of 'ä' against 'a' is locale-tailored (de/sv differ from dk/fr).", + "number-format/default" to + "No ICU: grouping separators are locale data we do not have.", + "number-format/precision" to + "No ICU: grouping separators, plus Double runs out of precision past 15 digits.", + "number-format/currency" to + "No ICU: currency symbols and their placement are locale data we do not have.", + "number-format/unit" to + "No ICU: CLDR unit abbreviations are locale data we do not have.", + "to-rgba/alpha" to + "Compose Color stores 8-bit channels, so alpha round-trips as 128/255, not 0.5.", + "to-string/color" to + "Compose Color stores 8-bit channels, so the alpha renders as 0.502, not 0.5.", + ) + } +} diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/InterpolateTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/InterpolateTest.kt new file mode 100644 index 00000000..40b8376d --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/InterpolateTest.kt @@ -0,0 +1,134 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import ovh.plrapps.mapcompose.vector.data.json +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsColor +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsDouble +import ovh.plrapps.mapcompose.vector.spec.style.serializers.ExpressionOrValueSerializer +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The interpolation curves. The previous implementation parsed `cubic-bezier` and `step` and then + * silently used linear for all of them, and hard-coded an exponential base of 1 (also linear). + */ +class InterpolateTest { + + private val bare = Json { ignoreUnknownKeys = true } + + private fun number(source: String, zoom: Double): Double? = + bare.decodeFromString(ExpressionOrValueSerializer(Double.serializer()), source).processAsDouble(zoom = zoom) + + private fun assertClose(expected: Double, actual: Double?, tolerance: Double = 1e-9) { + assertTrue(actual != null && abs(expected - actual) <= tolerance, "expected $expected, got $actual") + } + + @Test + fun linearInterpolationIsProportional() { + val src = """["interpolate",["linear"],["zoom"],0,0,10,100]""" + assertClose(0.0, number(src, 0.0)) + assertClose(50.0, number(src, 5.0)) + assertClose(100.0, number(src, 10.0)) + // Outside the stop range the endpoints are held. + assertClose(0.0, number(src, -5.0)) + assertClose(100.0, number(src, 20.0)) + } + + /** + * With base 2 the midpoint is `(2^0.5 - 1) / (2^1 - 1)` ≈ 0.4142 of the way, not 0.5. The old + * implementation ignored the base for the scalar `"exponential"` head and produced 0.5. + */ + @Test + fun exponentialInterpolationHonoursItsBase() { + val src = """["interpolate",["exponential",2],["zoom"],0,0,1,100]""" + assertClose(41.42135623730951, number(src, 0.5), tolerance = 1e-9) + } + + @Test + fun exponentialBaseOneIsLinear() { + val src = """["interpolate",["exponential",1],["zoom"],0,0,10,100]""" + assertClose(50.0, number(src, 5.0)) + } + + /** `cubic-bezier(0, 0, 1, 1)` is the identity, so it must agree with linear. */ + @Test + fun cubicBezierIdentityMatchesLinear() { + val src = """["interpolate",["cubic-bezier",0,0,1,1],["zoom"],0,0,10,100]""" + assertClose(50.0, number(src, 5.0), tolerance = 1e-5) + } + + /** `cubic-bezier(0.5, 0, 0.5, 1)` is symmetric: it eases in and out around the midpoint. */ + @Test + fun cubicBezierEaseInOutIsSymmetric() { + val src = """["interpolate",["cubic-bezier",0.5,0,0.5,1],["zoom"],0,0,10,100]""" + assertClose(50.0, number(src, 5.0), tolerance = 1e-4) + val quarter = number(src, 2.5)!! + val threeQuarter = number(src, 7.5)!! + assertTrue(quarter < 25.0, "ease-in should lag linear, got $quarter") + assertTrue(threeQuarter > 75.0, "ease-out should lead linear, got $threeQuarter") + assertClose(100.0, quarter + threeQuarter, tolerance = 1e-4) + } + + @Test + fun colorsInterpolateInSrgb() { + val expr = json.decodeFromString>( + """["interpolate",["linear"],["zoom"],0,"#000000",10,"#ffffff"]""" + ) + val mid = expr.processAsColor(zoom = 5.0)!! + // 8-bit storage, so the midpoint lands on 128/255. + assertTrue(abs(mid.red - 0.5f) <= 1f / 255f, "red was ${mid.red}") + assertEquals(mid.red, mid.green) + assertEquals(mid.red, mid.blue) + } + + /** + * `interpolate-hcl` and `interpolate-lab` go through a perceptual color space, so the midpoint + * between red and blue is far from the sRGB midpoint. + * + * The expected values were computed by running MapLibre's own `color_spaces.ts` formulas in + * node against the same inputs, so this is a cross-check against the reference implementation + * rather than a snapshot of our own output. + */ + @Test + fun hclAndLabMatchTheReferenceColorSpaceConversions() { + fun mid(operator: String): Color = json.decodeFromString>( + """["$operator",["linear"],["zoom"],0,"#ff0000",10,"#0000ff"]""" + ).processAsColor(zoom = 5.0)!! + + val srgb = mid("interpolate") + val hcl = mid("interpolate-hcl") + val lab = mid("interpolate-lab") + + assertEquals(Color(0.5019608f, 0f, 0.5019608f, 1f), srgb) + + // Reference: labToRgb(lerp(rgbToLab(red), rgbToLab(blue), 0.5)) + assertChannels(expected = listOf(0.756820, 0.0, 0.534008), actual = lab) + // Reference: hclToRgb over the shortest hue path from red to blue + assertChannels(expected = listOf(0.960586, 0.0, 0.525883), actual = hcl) + + assertTrue(hcl != srgb, "interpolate-hcl should not match the sRGB midpoint") + assertTrue(lab != srgb, "interpolate-lab should not match the sRGB midpoint") + } + + /** Compares against reference channel values, allowing for Compose Color's 8-bit storage. */ + private fun assertChannels(expected: List, actual: Color) { + val channels = listOf(actual.red, actual.green, actual.blue) + for (i in expected.indices) { + assertTrue( + abs(expected[i] - channels[i]) <= 1.0 / 255.0, + "channel $i: expected ${expected[i]}, got ${channels[i]}", + ) + } + } + + @Test + fun aSingleStopEvaluatesToThatStop() { + assertClose(7.0, number("""["interpolate",["linear"],["zoom"],5,7]""", 0.0)) + assertClose(7.0, number("""["interpolate",["linear"],["zoom"],5,7]""", 100.0)) + } +} diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/LegacyFunctionTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/LegacyFunctionTest.kt new file mode 100644 index 00000000..17679188 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/LegacyFunctionTest.kt @@ -0,0 +1,122 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +import androidx.compose.ui.graphics.Color +import ovh.plrapps.mapcompose.vector.data.json +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsColor +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsDouble +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsString +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Legacy v7 *function objects* (`{stops}`, `{type}`, `{property}`, `{base}`), which are rewritten + * into expressions before compilation. Replaces the old `NormalizeLegacyExpressionTest`, which + * asserted the shape of the rewritten JSON; these assert what the rewritten expression evaluates to, + * which is what actually matters. + */ +class LegacyFunctionTest { + + private fun feature(vararg properties: Pair) = + EvalFeature(type = "Polygon", properties = properties.toMap()) + + @Test + fun zoomStopsOnAnInterpolatablePropertyBecomeAnInterpolate() { + val expr = json.decodeFromString>("""{"stops":[[0,1],[10,11]]}""") + assertEquals(1.0, expr.processAsDouble(zoom = 0.0)) + assertEquals(6.0, expr.processAsDouble(zoom = 5.0)) + assertEquals(11.0, expr.processAsDouble(zoom = 10.0)) + } + + /** + * A string property is not interpolatable, so the same shorthand becomes a `step`. Getting this + * wrong turns `text-field` stops into an "is not interpolatable" parse error. + */ + @Test + fun zoomStopsOnAStringPropertyBecomeAStep() { + val expr = json.decodeFromString>( + """{"stops":[[2,"{ABBREV}"],[4,"{NAME}"]]}""" + ) + assertEquals("{ABBREV}", expr.processAsString(zoom = 2.0)) + assertEquals("{ABBREV}", expr.processAsString(zoom = 3.9)) + assertEquals("{NAME}", expr.processAsString(zoom = 4.0)) + } + + @Test + fun identityFunctionReadsTheProperty() { + val expr = json.decodeFromString>( + """{"type":"identity","property":"color"}""" + ) + assertEquals(Color(0xFFFF0000), expr.processAsColor(feature("color" to "#ff0000"))) + } + + @Test + fun exponentialFunctionInterpolatesOnTheProperty() { + val expr = json.decodeFromString>( + """{"type":"exponential","property":"population","base":1,"stops":[[0,0],[100,10]]}""" + ) + assertEquals(0.0, expr.processAsDouble(feature("population" to 0.0))) + assertEquals(5.0, expr.processAsDouble(feature("population" to 50.0))) + assertEquals(10.0, expr.processAsDouble(feature("population" to 100.0))) + } + + @Test + fun intervalFunctionBecomesAStepOnTheProperty() { + val expr = json.decodeFromString>( + """{"type":"interval","property":"rank","stops":[[0,1],[5,2],[10,3]]}""" + ) + assertEquals(1.0, expr.processAsDouble(feature("rank" to 0.0))) + assertEquals(1.0, expr.processAsDouble(feature("rank" to 4.9))) + assertEquals(2.0, expr.processAsDouble(feature("rank" to 5.0))) + assertEquals(3.0, expr.processAsDouble(feature("rank" to 100.0))) + } + + @Test + fun categoricalFunctionBecomesAMatch() { + val expr = json.decodeFromString>( + """{"type":"categorical","property":"kind","stops":[["a","#ff0000"],["b","#00ff00"]],"default":"#0000ff"}""" + ) + assertEquals(Color(0xFFFF0000), expr.processAsColor(feature("kind" to "a"))) + assertEquals(Color(0xFF00FF00), expr.processAsColor(feature("kind" to "b"))) + assertEquals(Color(0xFF0000FF), expr.processAsColor(feature("kind" to "z"))) + } + + @Test + fun categoricalFunctionWithBooleanStopsBecomesACase() { + val expr = json.decodeFromString>( + """{"type":"categorical","property":"flag","stops":[[true,1],[false,2]],"default":3}""" + ) + assertEquals(1.0, expr.processAsDouble(feature("flag" to true))) + assertEquals(2.0, expr.processAsDouble(feature("flag" to false))) + assertEquals(3.0, expr.processAsDouble(feature())) + } + + /** A zoom-and-property function: the stop inputs are `{zoom, value}` objects. */ + @Test + fun zoomAndPropertyFunctionNestsAPropertyFunctionInsideAZoomCurve() { + val expr = json.decodeFromString>( + """{"type":"exponential","property":"rank","base":1,"stops":[ + [{"zoom":0,"value":0},0],[{"zoom":0,"value":10},10], + [{"zoom":10,"value":0},0],[{"zoom":10,"value":10},100]]}""" + ) + assertEquals(5.0, expr.processAsDouble(feature("rank" to 5.0), zoom = 0.0)) + assertEquals(50.0, expr.processAsDouble(feature("rank" to 5.0), zoom = 10.0)) + // Halfway in zoom between the two property curves. + assertEquals(27.5, expr.processAsDouble(feature("rank" to 5.0), zoom = 5.0)) + } + + /** + * A constant legacy function must stay constant. + * + * Upstream's `fixupDegenerateStepCurve` reads the array slot it has just written and appends a + * second `0`, which makes the curve evaluate to `0` above zoom 0; this port repeats the output + * instead. See the note on that function. + */ + @Test + fun aSingleStopFunctionIsConstant() { + val expr = json.decodeFromString>("""{"stops":[[0,"only"]]}""") + assertEquals("only", expr.processAsString(zoom = 0.0)) + assertEquals("only", expr.processAsString(zoom = 10.0)) + assertEquals("only", expr.processAsString(zoom = 22.0)) + } +} diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/StyleExpressionErrorTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/StyleExpressionErrorTest.kt new file mode 100644 index 00000000..7fdaf2b2 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/expression/StyleExpressionErrorTest.kt @@ -0,0 +1,109 @@ +package ovh.plrapps.mapcompose.vector.spec.style.expression + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The error policy: parsing collects errors instead of throwing, and evaluation falls back to the + * property default with a warning logged once per distinct failure. + */ +class StyleExpressionErrorTest { + + private val warnings = mutableListOf() + private val originalWarn = ExpressionLogger.warn + + private fun captureWarnings() { + warnings.clear() + ExpressionLogger.warn = { warnings.add(it) } + } + + @AfterTest + fun restoreLogger() { + ExpressionLogger.warn = originalWarn + } + + @Test + fun anUnknownOperatorIsAParseErrorNotAThrow() { + val result = createExpression(listOf("nope", 1.0), "layers[0].paint.line-width") + assertTrue(result is ExpressionResult.Error) + assertEquals(1, result.errors.size) + assertTrue( + result.errors[0].message.startsWith("Unknown expression \"nope\""), + "unexpected message: ${result.errors[0].message}", + ) + } + + @Test + fun aMalformedExpressionIsAParseErrorNotAThrow() { + val result = createExpression(listOf("get"), "layers[0].paint.line-width") + assertTrue(result is ExpressionResult.Error) + assertTrue(result.errors.isNotEmpty()) + } + + @Test + fun aRuntimeErrorFallsBackToThePropertyDefault() { + captureWarnings() + val spec = StylePropertySpec(expectedType = NumberType, defaultValue = 1.0) + val compiled = createExpression(listOf("number", listOf("get", "x")), "layers[0].paint.line-width", spec) + assertTrue(compiled is ExpressionResult.Success) + + val value = compiled.value.evaluate( + globals = GlobalProperties(zoom = 0.0), + feature = EvalFeature(type = "Point", properties = mapOf("x" to "not a number")), + ) + assertEquals(1.0, value) + assertEquals(1, warnings.size) + assertTrue("layers[0].paint.line-width" in warnings[0], "missing root key: ${warnings[0]}") + assertTrue("Falling back to 1" in warnings[0], "missing fallback note: ${warnings[0]}") + } + + @Test + fun repeatedIdenticalRuntimeErrorsWarnOnlyOnce() { + captureWarnings() + val spec = StylePropertySpec(expectedType = NumberType, defaultValue = 1.0) + val compiled = createExpression(listOf("number", listOf("get", "x")), "layers[0].paint.line-width", spec) + assertTrue(compiled is ExpressionResult.Success) + + val feature = EvalFeature(type = "Point", properties = mapOf("x" to "not a number")) + repeat(50) { + compiled.value.evaluate(globals = GlobalProperties(zoom = 0.0), feature = feature) + } + assertEquals(1, warnings.size) + } + + @Test + fun withoutADefaultTheFallbackIsNullSoCallersCanSupplyTheirOwn() { + captureWarnings() + val spec = StylePropertySpec(expectedType = NumberType) + val compiled = createExpression(listOf("number", listOf("get", "x")), "layers[0].paint.line-width", spec) + assertTrue(compiled is ExpressionResult.Success) + + assertNull( + compiled.value.evaluate( + globals = GlobalProperties(zoom = 0.0), + feature = EvalFeature(type = "Point", properties = mapOf("x" to "not a number")), + ) + ) + } + + @Test + fun evaluateWithoutErrorHandlingStillThrows() { + val spec = StylePropertySpec(expectedType = NumberType, defaultValue = 1.0) + val compiled = createExpression(listOf("number", listOf("get", "x")), "layers[0].paint.line-width", spec) + assertTrue(compiled is ExpressionResult.Success) + + var threw = false + try { + compiled.value.evaluateWithoutErrorHandling( + globals = GlobalProperties(zoom = 0.0), + feature = EvalFeature(type = "Point", properties = mapOf("x" to "not a number")), + ) + } catch (e: RuntimeError) { + threw = true + } + assertTrue(threw, "evaluateWithoutErrorHandling should propagate the error") + } +} diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/filter/FeatureFilterTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/filter/FeatureFilterTest.kt new file mode 100644 index 00000000..343acef2 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/filter/FeatureFilterTest.kt @@ -0,0 +1,204 @@ +package ovh.plrapps.mapcompose.vector.spec.style.filter + +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExpressionResult +import ovh.plrapps.mapcompose.vector.spec.style.expression.GlobalProperties +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The layer-filter front end: syntax classification, legacy-to-expression conversion, and + * evaluation. A filter is just a boolean expression, so operator behaviour itself is covered by + * `ExpressionConformanceTest`. + */ +class FeatureFilterTest { + + private fun compile(filter: Any?): FeatureFilter? = + when (val r = featureFilter(filter, rootKey = "layers[0].filter")) { + is ExpressionResult.Success -> r.value + is ExpressionResult.Error -> throw AssertionError("failed to compile: ${r.errors}") + } + + private fun matches( + filter: Any?, + properties: Map = emptyMap(), + type: String = "Point", + id: Any? = null, + zoom: Double = 10.0, + ): Boolean { + val compiled = compile(filter) ?: return true + return compiled.filter( + GlobalProperties(zoom = zoom), + EvalFeature(type = type, id = id, properties = properties), + ) + } + + // region classification + + @Test + fun legacyOnlyOperatorsAreClassifiedAsLegacy() { + assertEquals(FilterClassification.LEGACY, classifyFilter(listOf("!in", "class", "a"))) + assertEquals(FilterClassification.LEGACY, classifyFilter(listOf("!has", "class"))) + assertEquals(FilterClassification.LEGACY, classifyFilter(listOf("none", listOf("==", "a", 1.0)))) + assertEquals(FilterClassification.LEGACY, classifyFilter(listOf("==", "class", "street"))) + } + + @Test + fun expressionOnlyFormsAreClassifiedAsExpression() { + assertEquals(FilterClassification.EXPRESSION, classifyFilter(listOf("==", listOf("get", "class"), "street"))) + assertEquals(FilterClassification.EXPRESSION, classifyFilter(listOf("!", listOf("has", "class")))) + assertEquals( + FilterClassification.EXPRESSION, + classifyFilter(listOf("in", listOf("get", "c"), listOf("literal", listOf("a", "b")))), + ) + } + + /** + * A node that reads the same in both syntaxes must not decide the syntax of the tree it sits + * in — otherwise `["all", ["has","x"], ["==", ["get","y"], 1]]` would be misread as legacy. + */ + @Test + fun neutralNodesDoNotDecideTheTreeSyntax() { + assertEquals(FilterClassification.NEUTRAL, classifyFilter(listOf("has", "class"))) + assertEquals(FilterClassification.NEUTRAL, classifyFilter(true)) + assertEquals( + FilterClassification.EXPRESSION, + classifyFilter(listOf("all", listOf("has", "x"), listOf("==", listOf("get", "y"), 1.0))), + ) + assertEquals( + FilterClassification.LEGACY, + classifyFilter(listOf("all", listOf("has", "x"), listOf("==", "y", 1.0))), + ) + } + + // endregion + + // region legacy conversion + + @Test + fun legacyComparisonsConvertToInternalFilterOperators() { + assertEquals(listOf("filter-==", "class", "street"), convertLegacyFilter(listOf("==", "class", "street"))) + assertEquals( + listOf("!", listOf("filter-==", "class", "street")), + convertLegacyFilter(listOf("!=", "class", "street")), + ) + assertEquals(listOf("filter-<", "rank", 3.0), convertLegacyFilter(listOf("<", "rank", 3.0))) + } + + @Test + fun dollarTypeAndDollarIdMapToGeometryTypeAndId() { + assertEquals(listOf("filter-type-==", "LineString"), convertLegacyFilter(listOf("==", "\$type", "LineString"))) + assertEquals(listOf("filter-id-==", 5.0), convertLegacyFilter(listOf("==", "\$id", 5.0))) + assertEquals(true, convertLegacyFilter(listOf("has", "\$type"))) + assertEquals(listOf("filter-has-id"), convertLegacyFilter(listOf("has", "\$id"))) + } + + @Test + fun legacyNoneBecomesAllOfNegations() { + assertEquals( + listOf("all", listOf("!", listOf("filter-==", "a", 1.0)), listOf("!", listOf("filter-==", "b", 2.0))), + convertLegacyFilter(listOf("none", listOf("==", "a", 1.0), listOf("==", "b", 2.0))), + ) + } + + @Test + fun legacyNotInBecomesNegatedIn() { + assertEquals( + listOf("!", listOf("filter-in-small", "class", listOf("literal", listOf("a", "b")))), + convertLegacyFilter(listOf("!in", "class", "a", "b")), + ) + } + + // endregion + + // region evaluation + + @Test + fun everyLegacyOperatorEvaluates() { + assertTrue(matches(listOf("==", "class", "street"), mapOf("class" to "street"))) + assertFalse(matches(listOf("==", "class", "street"), mapOf("class" to "path"))) + assertTrue(matches(listOf("!=", "class", "street"), mapOf("class" to "path"))) + assertTrue(matches(listOf("<", "rank", 3.0), mapOf("rank" to 1.0))) + assertTrue(matches(listOf("<=", "rank", 3.0), mapOf("rank" to 3.0))) + assertTrue(matches(listOf(">", "rank", 3.0), mapOf("rank" to 4.0))) + assertTrue(matches(listOf(">=", "rank", 3.0), mapOf("rank" to 3.0))) + assertTrue(matches(listOf("in", "class", "a", "b"), mapOf("class" to "b"))) + assertFalse(matches(listOf("in", "class", "a", "b"), mapOf("class" to "c"))) + assertTrue(matches(listOf("!in", "class", "a", "b"), mapOf("class" to "c"))) + assertTrue(matches(listOf("has", "class"), mapOf("class" to "x"))) + assertFalse(matches(listOf("has", "class"), emptyMap())) + assertTrue(matches(listOf("!has", "class"), emptyMap())) + assertTrue(matches(listOf("all", listOf("has", "a"), listOf("has", "b")), mapOf("a" to 1.0, "b" to 2.0))) + assertFalse(matches(listOf("all", listOf("has", "a"), listOf("has", "b")), mapOf("a" to 1.0))) + assertTrue(matches(listOf("any", listOf("has", "a"), listOf("has", "b")), mapOf("b" to 2.0))) + assertTrue(matches(listOf("none", listOf("has", "a")), emptyMap())) + } + + @Test + fun legacyTypeAndIdFiltersEvaluate() { + assertTrue(matches(listOf("==", "\$type", "LineString"), type = "LineString")) + assertFalse(matches(listOf("==", "\$type", "LineString"), type = "Polygon")) + assertTrue(matches(listOf("in", "\$type", "Point", "LineString"), type = "Point")) + assertTrue(matches(listOf("==", "\$id", 5.0), id = 5.0)) + assertTrue(matches(listOf("has", "\$id"), id = 1.0)) + assertFalse(matches(listOf("has", "\$id"))) + } + + /** + * Legacy semantics: a comparison against a property of the wrong type yields false rather than + * an error, so the surrounding `any` can still succeed on another branch. + */ + @Test + fun legacyOrderingAgainstAWrongTypedPropertyIsFalseNotAnError() { + assertFalse(matches(listOf(">", "rank", 3.0), mapOf("rank" to "not a number"))) + assertTrue( + matches( + listOf("any", listOf(">", "rank", 3.0), listOf("==", "class", "street")), + mapOf("rank" to "not a number", "class" to "street"), + ) + ) + } + + @Test + fun expressionSyntaxFiltersEvaluate() { + assertTrue(matches(listOf("==", listOf("get", "class"), "street"), mapOf("class" to "street"))) + assertTrue(matches(listOf("!", listOf("has", "class")), emptyMap())) + assertTrue( + matches( + listOf("in", listOf("get", "class"), listOf("literal", listOf("a", "b"))), + mapOf("class" to "b"), + ) + ) + assertTrue(matches(listOf("<=", listOf("zoom"), 12.0), zoom = 10.0)) + assertFalse(matches(listOf("<=", listOf("zoom"), 12.0), zoom = 14.0)) + } + + @Test + fun anAbsentFilterCompilesToNull() { + assertNull(compile(null)) + } + + // endregion + + @Test + fun geometryIsOnlyNeededByWithinAndDistance() { + assertFalse(geometryNeeded(listOf("==", listOf("get", "x"), 1.0))) + assertTrue(geometryNeeded(listOf("within", mapOf("type" to "Polygon", "coordinates" to emptyList())))) + assertTrue( + geometryNeeded( + listOf("all", listOf("has", "x"), listOf("distance", mapOf("type" to "Point"))) + ) + ) + } + + @Test + fun needGeometryIsExposedOnTheCompiledFilter() { + val plain = compile(listOf("==", listOf("get", "x"), 1.0)) + assertNotNull(plain) + assertFalse(plain.needGeometry) + } +} diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/filter/FilterRegressionTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/filter/FilterRegressionTest.kt new file mode 100644 index 00000000..109a3724 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/filter/FilterRegressionTest.kt @@ -0,0 +1,154 @@ +package ovh.plrapps.mapcompose.vector.spec.style.filter + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import ovh.plrapps.mapcompose.vector.data.json +import ovh.plrapps.mapcompose.vector.spec.style.Layer +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature +import ovh.plrapps.mapcompose.vector.spec.style.expression.ExpressionResult +import ovh.plrapps.mapcompose.vector.spec.style.expression.GlobalProperties +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsColor +import ovh.plrapps.mapcompose.vector.spec.style.props.processAsDouble +import ovh.plrapps.mapcompose.vector.spec.style.serializers.ExpressionOrValueSerializer +import ovh.plrapps.mapcompose.vector.spec.style.utils.StyleDiagnostics +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * One test per defect found in the audit that motivated the port to MapLibre's engine. Each of + * these silently produced wrong rendering (or refused to load the style) under the previous + * hand-rolled implementation. + */ +class FilterRegressionTest { + + private val bare = Json { ignoreUnknownKeys = true } + + private fun matches(filter: Any?, feature: EvalFeature, zoom: Double = 10.0): Boolean { + val compiled = when (val r = featureFilter(filter, rootKey = "layers[0].filter")) { + is ExpressionResult.Success -> r.value + is ExpressionResult.Error -> throw AssertionError("failed to compile: ${r.errors}") + } ?: return true + return compiled.filter(GlobalProperties(zoom = zoom), feature) + } + + /** + * `["geometry-type"]` used to be mapped to a `get` of the property key `"type"`, while the + * renderer wrote the geometry type under `"$type"` — so it always compared against null and + * every such layer rendered nothing. The swisstopo style relies on this in many layers. + */ + @Test + fun geometryTypeFilterMatchesTheFeatureGeometry() { + val filter = listOf("==", listOf("geometry-type"), "LineString") + assertTrue(matches(filter, EvalFeature(type = "LineString"))) + assertFalse(matches(filter, EvalFeature(type = "Polygon"))) + } + + /** `["!"]` was not a known filter operator and threw, aborting the entire style parse. */ + @Test + fun notOperatorIsSupported() { + assertTrue(matches(listOf("!", listOf("has", "x")), EvalFeature(type = "Point"))) + assertFalse( + matches( + listOf("!", listOf("has", "x")), + EvalFeature(type = "Point", properties = mapOf("x" to 1.0)), + ) + ) + } + + /** The spec form of `in`, with the haystack wrapped in `literal`, used to parse to a null operand. */ + @Test + fun modernInWithLiteralHaystackMatches() { + val filter = listOf("in", listOf("get", "c"), listOf("literal", listOf("a", "b"))) + assertTrue(matches(filter, EvalFeature(type = "Point", properties = mapOf("c" to "b")))) + assertFalse(matches(filter, EvalFeature(type = "Point", properties = mapOf("c" to "z")))) + } + + /** + * MVT decodes numbers as Int/Float/Long; the filter engine compared with raw `==` in some + * branches and with numeric coercion in others, so the same value matched or not depending on + * which operator was used. + */ + @Test + fun numericComparisonsIgnoreTheBoxedNumberType() { + val eq = listOf("==", listOf("get", "n"), 1.0) + val inOp = listOf("in", listOf("get", "n"), listOf("literal", listOf(1.0, 2.0))) + for (value in listOf(1, 1L, 1.0f, 1.0)) { + val feature = EvalFeature(type = "Point", properties = mapOf("n" to value)) + assertTrue(matches(eq, feature), "== failed for ${value::class.simpleName}") + assertTrue(matches(inOp, feature), "in failed for ${value::class.simpleName}") + } + } + + /** `["zoom"]` outside an interpolate/step used to throw when parsed and be null in filters. */ + @Test + fun zoomIsUsableDirectlyInAFilter() { + val filter = listOf("<=", listOf("zoom"), 12.0) + assertTrue(matches(filter, EvalFeature(type = "Point"), zoom = 10.0)) + assertFalse(matches(filter, EvalFeature(type = "Point"), zoom = 14.0)) + } + + /** `$id` was never populated, so an id filter could never match. */ + @Test + fun featureIdFiltersMatch() { + assertTrue(matches(listOf("==", "\$id", 7.0), EvalFeature(type = "Point", id = 7.0))) + assertFalse(matches(listOf("==", "\$id", 7.0), EvalFeature(type = "Point", id = 8.0))) + } + + /** + * An unrecognised operator used to throw out of the serializer, which `getMapLibreConfiguration` + * turned into a `Result.failure` for the *whole* style. Now it is a diagnostic and the layer + * simply keeps all of its features. + */ + @Test + fun anUnknownOperatorDegradesToNoFilterInsteadOfKillingTheStyle() { + StyleDiagnostics.drain() + val layerJson = """ + {"id":"l","type":"line","source":"s","filter":["not-a-real-operator","x",1]} + """.trimIndent() + + val layer = json.decodeFromString(layerJson) + assertNotNull(layer) + assertEquals("l", layer.id) + // Compilation failed, so the layer has no usable filter and shows everything. + assertEquals(null, layer.filter?.filter) + + val diagnostics = StyleDiagnostics.drain() + assertTrue(diagnostics.isNotEmpty(), "expected a diagnostic for the unknown operator") + assertTrue(diagnostics.any { "not-a-real-operator" in it.message }) + } + + /** + * A data-driven `interpolate` used to cast its input with `as? Double` and returned null for any + * property the tile encoded as an Int or Float — which is the common case. + */ + @Test + fun dataDrivenInterpolateAcceptsNonDoubleProperties() { + val expr = bare.decodeFromString( + ExpressionOrValueSerializer(Double.serializer()), + """["interpolate",["linear"],["get","w"],0,1,10,11]""", + ) + assertEquals(1.0, expr.processAsDouble(EvalFeature("Point", properties = mapOf("w" to 0)))) + assertEquals(6.0, expr.processAsDouble(EvalFeature("Point", properties = mapOf("w" to 5L)))) + assertEquals(11.0, expr.processAsDouble(EvalFeature("Point", properties = mapOf("w" to 10.0f)))) + } + + /** + * A legacy `categorical` function used to emit a JSON null default, which then failed to decode + * for color- and number-valued properties and threw during style parsing. + */ + @Test + fun legacyCategoricalFunctionWithoutADefaultParses() { + StyleDiagnostics.drain() + val expr = json.decodeFromString>( + """{"property":"kind","type":"categorical","stops":[["a","#ff0000"],["b","#00ff00"]]}""" + ) + assertEquals(Color(0xFFFF0000), expr.processAsColor(EvalFeature("Polygon", properties = mapOf("kind" to "a")))) + assertEquals(Color(0xFF00FF00), expr.processAsColor(EvalFeature("Polygon", properties = mapOf("kind" to "b")))) + assertTrue(StyleDiagnostics.drain().isEmpty()) + } +} diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrValueTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrValueTest.kt new file mode 100644 index 00000000..5c42c393 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrValueTest.kt @@ -0,0 +1,194 @@ +package ovh.plrapps.mapcompose.vector.spec.style.props + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import ovh.plrapps.mapcompose.vector.data.json +import ovh.plrapps.mapcompose.vector.spec.style.expression.EvalFeature +import ovh.plrapps.mapcompose.vector.spec.style.serializers.ExpressionOrValueSerializer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The public façade over the expression engine: constants, expressions and the typed `processAs*` + * accessors the painters use. Operator semantics are covered exhaustively by + * `ExpressionConformanceTest`; this file covers the MapCompose-specific wrapper. + */ +class ExpressionOrValueTest { + + private val bare = Json { ignoreUnknownKeys = true } + + private fun feature(vararg properties: Pair, type: String = "Point") = + EvalFeature(type = type, properties = properties.toMap()) + + @Test + fun constantValueIsReturnedAsIs() { + val value = ExpressionOrValue.Value(42.0) + assertEquals(42.0, value.process()) + assertEquals(42.0, value.processAsDouble()) + assertEquals(42f, value.processAsFloat()) + } + + @Test + fun getReadsAFeatureProperty() { + val expr = bare.decodeFromString(ExpressionOrValueSerializer(String.serializer()), """["get","name"]""") + assertTrue(expr is ExpressionOrValue.Expression) + assertEquals("test", expr.processAsString(feature("name" to "test"))) + // String-valued properties are *coerced* at the top level rather than asserted, so a + // missing property yields "" — this is MapLibre's behaviour, see createExpression. + assertEquals("", expr.processAsString(feature())) + } + + @Test + fun zoomDrivenStepSelectsTheRightBranch() { + val expr = bare.decodeFromString( + ExpressionOrValueSerializer(String.serializer()), + """["step",["zoom"],"small",10,"medium",15,"large"]""", + ) + assertEquals("small", expr.processAsString(zoom = 8.0)) + assertEquals("medium", expr.processAsString(zoom = 10.0)) + assertEquals("medium", expr.processAsString(zoom = 14.9)) + assertEquals("large", expr.processAsString(zoom = 15.0)) + } + + @Test + fun matchFallsBackToItsDefault() { + val expr = json.decodeFromString>( + """["match",["get","class"],["school","university"],["get","class"],["case",["has","class"],"","dot"]]""" + ) + assertEquals("school", expr.processAsString(feature("class" to "school"))) + assertEquals("", expr.processAsString(feature("class" to "park"))) + assertEquals("dot", expr.processAsString(feature())) + } + + /** + * MVT decodes numeric properties as Int/Float/Long; the engine normalizes them to Double so a + * numeric literal in the style still matches. This is the regression for the two engines + * previously disagreeing on numeric equality. + */ + @Test + fun numericPropertiesCompareByValueNotByBoxedType() { + val expr = bare.decodeFromString( + ExpressionOrValueSerializer(Boolean.serializer()), + """["==",["get","rank"],1]""", + ) + assertEquals(true, expr.processAsBoolean(feature("rank" to 1.0))) + assertEquals(true, expr.processAsBoolean(feature("rank" to 1))) + assertEquals(true, expr.processAsBoolean(feature("rank" to 1L))) + assertEquals(false, expr.processAsBoolean(feature("rank" to 2.0))) + } + + @Test + fun colorExpressionsEvaluateToColors() { + val expr = json.decodeFromString>( + """["case",["==",["get","kind"],"water"],"#0000FF","#FF0000"]""" + ) + assertEquals(Color(0xFF0000FF), expr.processAsColor(feature("kind" to "water"))) + assertEquals(Color(0xFFFF0000), expr.processAsColor(feature("kind" to "land"))) + } + + @Test + fun listAccessorsCoerceElementTypes() { + val dashes = bare.decodeFromString( + ExpressionOrValueSerializer(kotlinx.serialization.builtins.ListSerializer(Double.serializer())), + """["literal",[2,4]]""", + ) + assertEquals(listOf(2.0, 4.0), dashes.processAsDoubleList()) + + val fonts = bare.decodeFromString( + ExpressionOrValueSerializer(kotlinx.serialization.builtins.ListSerializer(String.serializer())), + """["literal",["Roboto","Noto"]]""", + ) + assertEquals(listOf("Roboto", "Noto"), fonts.processAsStringList()) + } + + @Test + fun concatBuildsAStringFromProperties() { + val expr = bare.decodeFromString( + ExpressionOrValueSerializer(String.serializer()), + """["concat",["get","a"]," - ",["get","b"]]""", + ) + assertEquals("x - y", expr.processAsString(feature("a" to "x", "b" to "y"))) + } + + @Test + fun caseAndUpcaseCombine() { + val expr = bare.decodeFromString( + ExpressionOrValueSerializer(String.serializer()), + """["upcase",["downcase",["get","name"]]]""", + ) + assertEquals("HELLO WORLD", expr.processAsString(feature("name" to "Hello World"))) + } + + @Test + fun indexOfAndSliceOperateOnStrings() { + val indexOf = bare.decodeFromString( + ExpressionOrValueSerializer(Double.serializer()), + """["index-of","c","abcdef"]""", + ) + assertEquals(2.0, indexOf.processAsDouble()) + + val slice = bare.decodeFromString( + ExpressionOrValueSerializer(String.serializer()), + """["slice","abcdef",1,3]""", + ) + assertEquals("bc", slice.processAsString()) + } + + @Test + fun letAndVarBindIntermediateValues() { + val expr = bare.decodeFromString( + ExpressionOrValueSerializer(Double.serializer()), + """["let","r",["get","rank"],["*",["var","r"],2]]""", + ) + assertEquals(8.0, expr.processAsDouble(feature("rank" to 4.0))) + } + + @Test + fun arithmeticOperatorsEvaluate() { + fun number(source: String) = + bare.decodeFromString(ExpressionOrValueSerializer(Double.serializer()), source).processAsDouble() + + assertEquals(7.0, number("""["+",3,4]""")) + assertEquals(-1.0, number("""["-",3,4]""")) + assertEquals(12.0, number("""["*",3,4]""")) + assertEquals(0.75, number("""["/",3,4]""")) + assertEquals(3.0, number("""["%",7,4]""")) + assertEquals(81.0, number("""["^",3,4]""")) + } + + @Test + fun rgbBuildsAColor() { + val expr = json.decodeFromString>("""["rgb",255,0,0]""") + assertEquals(Color(0xFFFF0000), expr.processAsColor()) + } + + @Test + fun geometryTypeReadsTheFeatureGeometry() { + val expr = bare.decodeFromString( + ExpressionOrValueSerializer(Boolean.serializer()), + """["==",["geometry-type"],"LineString"]""", + ) + assertEquals(true, expr.processAsBoolean(feature(type = "LineString"))) + assertEquals(false, expr.processAsBoolean(feature(type = "Polygon"))) + } + + @Test + fun featureIdIsAvailableToExpressions() { + val expr = bare.decodeFromString( + ExpressionOrValueSerializer(Boolean.serializer()), + """["==",["id"],7]""", + ) + assertEquals(true, expr.processAsBoolean(EvalFeature(type = "Point", id = 7.0))) + assertEquals(false, expr.processAsBoolean(EvalFeature(type = "Point", id = 8.0))) + } + + @Test + fun isExpressionRecognisesArraysAndLegacyObjects() { + assertTrue(ExpressionOrValue.isExpression(json.parseToJsonElement("""["get","x"]"""))) + assertTrue(ExpressionOrValue.isExpression(json.parseToJsonElement("""{"stops":[[0,1]]}"""))) + assertTrue(!ExpressionOrValue.isExpression(json.parseToJsonElement("""["a","b"]"""))) + assertTrue(!ExpressionOrValue.isExpression(json.parseToJsonElement("""5"""))) + } +} diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/ObbTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/ObbTest.kt new file mode 100644 index 00000000..9c4d4459 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/utils/obb/ObbTest.kt @@ -0,0 +1,392 @@ +package ovh.plrapps.mapcompose.vector.utils.obb + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.measureTime + +class ObbTest { + @Test + fun `test basic OBB creation`() { + val obb = OBB( + center = ObbPoint(10f, 10f), + size = Size(20f, 10f), + rotation = 0f + ) + + val corners = obb.getCorners() + assertEquals(4, corners.size) + + // For 0 rotation, corners should be: + // (-10, -5), (10, -5), (10, 5), (-10, 5) + val expectedCorners = listOf( + ObbPoint(0f, 5f), // (-10 + 10, -5 + 10) + ObbPoint(20f, 5f), // (10 + 10, -5 + 10) + ObbPoint(20f, 15f), // (10 + 10, 5 + 10) + ObbPoint(0f, 15f) // (-10 + 10, 5 + 10) + ) + + corners.forEachIndexed { index, corner -> + assertTrue( + abs(corner.x - expectedCorners[index].x) < 0.001f, + "X coordinate mismatch at corner $index" + ) + assertTrue( + abs(corner.y - expectedCorners[index].y) < 0.001f, + "Y coordinate mismatch at corner $index" + ) + } + } + + @Test + fun `test rotated OBB`() { + val obb = OBB( + center = ObbPoint(0f, 0f), + size = Size(20f, 10f), + rotation = 90f + ) + + val corners = obb.getCorners() + + // For 90-degree rotation, corners should be: + // (5, -10), (5, 10), (-5, 10), (-5, -10) + val expectedCorners = listOf( + ObbPoint(5f, -10f), + ObbPoint(5f, 10f), + ObbPoint(-5f, 10f), + ObbPoint(-5f, -10f) + ) + + corners.forEachIndexed { index, corner -> + assertTrue( + abs(corner.x - expectedCorners[index].x) < 0.001f, + "X coordinate mismatch at corner $index" + ) + assertTrue( + abs(corner.y - expectedCorners[index].y) < 0.001f, + "Y coordinate mismatch at corner $index" + ) + } + } + + @Test + fun `test OBB intersection`() { + // Two OBBs that clearly intersect + val obb1 = OBB( + center = ObbPoint(0f, 0f), + size = Size(20f, 10f), + rotation = 0f + ) + + val obb2 = OBB( + center = ObbPoint(5f, 5f), + size = Size(20f, 10f), + rotation = 45f + ) + + assertTrue(obb1.intersects(obb2)) + assertTrue(obb2.intersects(obb1)) + + // Two OBBs that clearly don't intersect + val obb3 = OBB( + center = ObbPoint(0f, 0f), + size = Size(20f, 10f), + rotation = 0f + ) + + val obb4 = OBB( + center = ObbPoint(30f, 30f), + size = Size(20f, 10f), + rotation = 0f + ) + + assertFalse(obb3.intersects(obb4)) + assertFalse(obb4.intersects(obb3)) + } + + @Test + fun `test OBB AABB conversion`() { + val obb = OBB( + center = ObbPoint(10f, 10f), + size = Size(20f, 10f), + rotation = 45f + ) + + val aabb = obb.getAABB() + + // AABB should contain all corners of the OBB + val corners = obb.getCorners() + corners.forEach { corner -> + assertTrue(corner.x >= aabb.minX && corner.x <= aabb.maxX) + assertTrue(corner.y >= aabb.minY && corner.y <= aabb.maxY) + } + } + + @Test + fun `test edge cases`() { + // Test with zero rotation + val obb1 = OBB( + center = ObbPoint(0f, 0f), + size = Size(10f, 10f), + rotation = 0f + ) + + // Test with 180 degree rotation + val obb2 = OBB( + center = ObbPoint(0f, 0f), + size = Size(10f, 10f), + rotation = 180f + ) + + // Test with negative rotation + val obb3 = OBB( + center = ObbPoint(0f, 0f), + size = Size(10f, 10f), + rotation = -45f + ) + + // Test with large rotation + val obb4 = OBB( + center = ObbPoint(0f, 0f), + size = Size(10f, 10f), + rotation = 720f // Two full rotations + ) + + // All these OBBs should be equivalent + assertTrue(obb1.intersects(obb2)) + assertTrue(obb2.intersects(obb3)) + assertTrue(obb3.intersects(obb4)) + } + + @Test + fun `test performance`() { + val numTests = 1000 + val obbs = List(numTests) { i -> + OBB( + center = ObbPoint(i * 10f, i * 10f), + size = Size(20f, 10f), + rotation = i * 10f + ) + } + + var intersections = 0 + val duration = measureTime { + // Test all pairs of OBBs + for (i in 0 until numTests) { + for (j in i + 1 until numTests) { + if (obbs[i].intersects(obbs[j])) { + intersections++ + } + } + } + } + + println("Performed $numTests OBB intersection tests in ${duration.inWholeMilliseconds}ms") + println("Found $intersections intersections") + + // Verify that the test didn't take too long + assertTrue(duration.inWholeMilliseconds < 1000, "Performance test took too long: ${duration.inWholeMilliseconds}ms") + } + + @Test + fun `test floating point precision`() { + val obb1 = OBB( + center = ObbPoint(0f, 0f), + size = Size(10f, 10f), + rotation = 45f + ) + + val obb2 = OBB( + center = ObbPoint(0.0001f, 0.0001f), + size = Size(10f, 10f), + rotation = 45f + ) + + assertTrue(obb1.intersects(obb2), "OBBs should intersect with small offset") + + // Test with very small size difference + val obb3 = OBB( + center = ObbPoint(0f, 0f), + size = Size(10.0001f, 10.0001f), + rotation = 45f + ) + + assertTrue(obb1.intersects(obb3), "OBBs should intersect with small size difference") + } + + @Test + fun `test nested OBBs`() { + val outer = OBB( + center = ObbPoint(0f, 0f), + size = Size(20f, 20f), + rotation = 0f + ) + + val inner = OBB( + center = ObbPoint(0f, 0f), + size = Size(10f, 10f), + rotation = 45f + ) + + assertTrue(outer.intersects(inner), "Outer OBB should intersect with inner OBB") + + // Test with inner OBB at different positions + val inner2 = OBB( + center = ObbPoint(5f, 5f), + size = Size(10f, 10f), + rotation = 45f + ) + + assertTrue(outer.intersects(inner2), "Outer OBB should intersect with offset inner OBB") + + // Test with inner OBB touching the edge + val inner3 = OBB( + center = ObbPoint(10f, 0f), + size = Size(10f, 10f), + rotation = 45f + ) + + assertTrue(outer.intersects(inner3), "Outer OBB should intersect with edge-touching inner OBB") + } + + @Test + fun `test parallel OBBs`() { + val obb1 = OBB( + center = ObbPoint(0f, 0f), + size = Size(10f, 10f), + rotation = 45f + ) + + val obb2 = OBB( + center = ObbPoint(15f, 15f), + size = Size(10f, 10f), + rotation = 45f + ) + + assertFalse(obb1.intersects(obb2), "Parallel OBBs should not intersect when separated") + + // Test with touching parallel OBBs + // For 45-degree rotation, the distance between centers should be 10/sqrt(2) ≈ 7.07 + val touchingObb = OBB( + center = ObbPoint(7.07f, 7.07f), + size = Size(10f, 10f), + rotation = 45f + ) + + assertTrue(obb1.intersects(touchingObb), "Parallel OBBs should intersect when touching") + + // Test with different sizes but same rotation + val obb4 = OBB( + center = ObbPoint(0f, 0f), + size = Size(20f, 20f), + rotation = 45f + ) + + assertTrue(obb1.intersects(obb4), "OBBs with same rotation but different sizes should intersect") + } + + @Test + fun `test edge cases with rotation`() { + // Test with very small rotation + val obb1 = OBB( + center = ObbPoint(0f, 0f), + size = Size(10f, 10f), + rotation = 0.001f + ) + + val obb2 = OBB( + center = ObbPoint(0f, 0f), + size = Size(10f, 10f), + rotation = 0f + ) + + assertTrue(obb1.intersects(obb2), "OBBs with very small rotation difference should intersect") + + // Test with very large rotation + val obb3 = OBB( + center = ObbPoint(0f, 0f), + size = Size(10f, 10f), + rotation = 360000f + ) + + assertTrue(obb2.intersects(obb3), "OBBs with very large rotation should be equivalent to 0 rotation") + } + + @Test + fun `test rotation sweep intersection`() { + // Create a fixed OBB in the center + val fixedObb = OBB( + center = ObbPoint(0f, 0f), + size = Size(10f, 10f), + rotation = 0f + ) + + // Create an array of OBBs that will rotate around the fixed one + val rotatingObbs = listOf( + // Should always intersect (centers coincide) + OBB(ObbPoint(0f, 0f), Size(10f, 10f), 0f), + // Should sometimes intersect (close to center) + OBB(ObbPoint(5f, 5f), Size(10f, 10f), 0f), + // Should sometimes intersect (on the boundary) + OBB(ObbPoint(10f, 10f), Size(10f, 10f), 0f), + // Should never intersect (too far) + OBB(ObbPoint(20f, 20f), Size(10f, 10f), 0f) + ) + + // Number of steps for a full rotation + val steps = 360 + val angleStep = 360f / steps + + // Counters for statistics + val intersectionStats = rotatingObbs.map { mutableMapOf() } + + // Check for each angle + for (step in 0 until steps) { + val angle = step * angleStep + + // Check each rotating OBB + rotatingObbs.forEachIndexed { index, obb -> + // Create a new OBB with current rotation angle + val rotatedObb = OBB( + center = obb.center, + size = obb.size, + rotation = angle + ) + + // Check intersection + val intersects = fixedObb.intersects(rotatedObb) + + // Update statistics + intersectionStats[index][intersects] = (intersectionStats[index][intersects] ?: 0) + 1 + + // Check expected behavior + when (index) { + 0 -> assertTrue(intersects, "Centered OBB should always intersect at angle $angle") + 3 -> assertFalse(intersects, "Distant OBB should never intersect at angle $angle") + } + } + } + + // Print statistics + println("\nIntersection statistics for full rotation:") + rotatingObbs.forEachIndexed { index, obb -> + val stats = intersectionStats[index] + val total = stats.values.sum() + val intersectionPercentage = (stats[true] ?: 0) * 100.0 / total + println("OBB $index (center: ${obb.center}, size: ${obb.size}):") + println(" Intersections: ${stats[true] ?: 0} (${intersectionPercentage.toInt()}%)") + println(" Non-intersections: ${stats[false] ?: 0} (${(100 - intersectionPercentage).toInt()}%)") + } + + // Verify that statistics match expectations + assertTrue(intersectionStats[0][true] == steps, "Centered OBB should intersect at all angles") + assertTrue(intersectionStats[3][false] == steps, "Distant OBB should never intersect") + + // Verify that boundary OBB has a reasonable number of intersections + val borderIntersections = intersectionStats[2][true] ?: 0 + assertTrue(borderIntersections > 0, "Border OBB should intersect at some angles") + assertTrue(borderIntersections < steps, "Border OBB should not intersect at all angles") + } +} \ No newline at end of file diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/utils/rtree/RtreeTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/utils/rtree/RtreeTest.kt new file mode 100644 index 00000000..4cb086a8 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/utils/rtree/RtreeTest.kt @@ -0,0 +1,483 @@ +package ovh.plrapps.mapcompose.vector.utils.rtree + +import kotlinx.datetime.Clock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RtreeTest { + @Test + fun `test basic insertion and search`() { + val rtree = Rtree() + val aabb1 = AABB(0f, 0f, 10f, 10f) + val aabb2 = AABB(5f, 5f, 15f, 15f) + + rtree.insert(aabb1, "item1") + rtree.insert(aabb2, "item2") + + val searchBounds = AABB(4f, 4f, 16f, 16f) + val results = rtree.search(searchBounds) + + assertEquals(2, results.size) + assertTrue(results.contains("item1")) + assertTrue(results.contains("item2")) + } + + @Test + fun `test node splitting`() { + val rtree = Rtree(maxEntries = 4, minEntries = 2) + + rtree.insert(AABB(0f, 0f, 10f, 10f), "item1") + rtree.insert(AABB(20f, 20f, 30f, 30f), "item2") + rtree.insert(AABB(40f, 40f, 50f, 50f), "item3") + rtree.insert(AABB(60f, 60f, 70f, 70f), "item4") + rtree.insert(AABB(80f, 80f, 90f, 90f), "item5") + + val allResults = rtree.search(AABB(0f, 0f, 100f, 100f)) + println("[test node splitting] allResults: $allResults, size: ${rtree.size()}") + assertEquals(5, allResults.size) + + val area1Results = rtree.search(AABB(0f, 0f, 15f, 15f)) + println("[test node splitting] area1Results: $area1Results") + assertEquals(1, area1Results.size) + assertTrue(area1Results.contains("item1")) + + val area2Results = rtree.search(AABB(15f, 15f, 35f, 35f)) + println("[test node splitting] area2Results: $area2Results") + assertEquals(1, area2Results.size) + assertTrue(area2Results.contains("item2")) + } + + @Test + fun `test AABB operations`() { + val aabb1 = AABB(0f, 0f, 10f, 10f) + val aabb2 = AABB(5f, 5f, 15f, 15f) + + assertTrue(aabb1.intersects(aabb2)) + assertTrue(aabb2.intersects(aabb1)) + + val union = aabb1.union(aabb2) + assertEquals(0f, union.minX) + assertEquals(0f, union.minY) + assertEquals(15f, union.maxX) + assertEquals(15f, union.maxY) + } + + @Test + fun `test size tracking`() { + val rtree = Rtree() + + assertEquals(0, rtree.size()) + + rtree.insert(AABB(0f, 0f, 10f, 10f), "item1") + assertEquals(1, rtree.size()) + + rtree.insert(AABB(5f, 5f, 15f, 15f), "item2") + assertEquals(2, rtree.size()) + } + + @Test + fun `test empty search results`() { + val rtree = Rtree() + rtree.insert(AABB(0f, 0f, 10f, 10f), "item1") + + val results = rtree.search(AABB(20f, 20f, 30f, 30f)) + assertTrue(results.isEmpty()) + } + + @Test + fun `test overlapping AABB`() { + val aabb1 = AABB(0f, 0f, 10f, 10f) + val aabb2 = AABB(10f, 10f, 20f, 20f) + val aabb3 = AABB(5f, 5f, 15f, 15f) + + assertTrue(aabb1.intersects(aabb3)) + assertTrue(aabb2.intersects(aabb3)) + assertTrue(aabb1.intersects(aabb2)) + } + + @Test + fun `test multiple levels of splitting`() { + val rtree = Rtree(maxEntries = 3, minEntries = 1) + + for (i in 0..9) { + rtree.insert(AABB(i * 10f, i * 10f, (i + 1) * 10f, (i + 1) * 10f), "item$i") + } + + val allResults = rtree.search(AABB(0f, 0f, 100f, 100f)) + assertEquals(10, allResults.size) + + val areaResults = rtree.search(AABB(25f, 25f, 35f, 35f)) + assertEquals(2, areaResults.size) + assertTrue(areaResults.contains("item2")) + assertTrue(areaResults.contains("item3")) + } + + @Test + fun `test AABB contains`() { + val outer = AABB(0f, 0f, 20f, 20f) + val inner = AABB(5f, 5f, 15f, 15f) + val overlapping = AABB(15f, 15f, 25f, 25f) + + assertTrue(outer.contains(inner)) + assertFalse(outer.contains(overlapping)) + assertFalse(inner.contains(outer)) + } + + @Test + fun `test boundary conditions`() { + val rtree = Rtree() + + // Test with zero dimensions (point) + rtree.insert(AABB(0f, 0f, 0f, 0f), "zero") + println("Tree size after insert: "+rtree.size()) + + // Search for the same point + val zeroResults1 = rtree.search(AABB(0f, 0f, 0f, 0f)) + println("zeroResults1: $zeroResults1") + assertEquals(1, zeroResults1.size) + assertTrue(zeroResults1.contains("zero")) + + // Search in area containing the point + val zeroResults2 = rtree.search(AABB(-1f, -1f, 1f, 1f)) + println("zeroResults2: $zeroResults2") + assertEquals(1, zeroResults2.size) + assertTrue(zeroResults2.contains("zero")) + + // Test with negative coordinates + rtree.insert(AABB(-10f, -10f, -5f, -5f), "negative") + println("Tree size after second insert: "+rtree.size()) + + // Full intersection + val negativeResults1 = rtree.search(AABB(-15f, -15f, 0f, 0f)) + println("negativeResults1: $negativeResults1") + assertEquals(2, negativeResults1.size) + assertTrue(negativeResults1.contains("negative")) + assertTrue(negativeResults1.contains("zero")) + + // Partial intersection + val negativeResults2 = rtree.search(AABB(-7f, -7f, -3f, -3f)) + println("negativeResults2: $negativeResults2") + assertEquals(1, negativeResults2.size) + assertTrue(negativeResults2.contains("negative")) + + // Boundary intersection (touching) + val negativeResults4 = rtree.search(AABB(-5f, -5f, 0f, 0f)) + println("negativeResults4: $negativeResults4") + assertEquals(2, negativeResults4.size) + assertTrue(negativeResults4.contains("negative")) + assertTrue(negativeResults4.contains("zero")) + + // Test with very large numbers + rtree.insert(AABB(1e6f, 1e6f, 1e7f, 1e7f), "large") + val largeResults = rtree.search(AABB(5e5f, 5e5f, 2e6f, 2e6f)) + println("largeResults: $largeResults") + assertEquals(1, largeResults.size) + assertTrue(largeResults.contains("large")) + + // Test with very small numbers + rtree.insert(AABB(1e-6f, 1e-6f, 1e-5f, 1e-5f), "small") + val smallResults = rtree.search(AABB(0f, 0f, 1e-4f, 1e-4f)) + println("smallResults: $smallResults") + assertTrue(smallResults.contains("small")) + assertTrue(smallResults.contains("zero")) + assertEquals(2, smallResults.size) + } + + @Test + fun `test empty tree operations`() { + val rtree = Rtree() + + // Check search in empty tree + val emptyResults = rtree.search(AABB(0f, 0f, 10f, 10f)) + assertTrue(emptyResults.isEmpty()) + + // Check size of empty tree + assertEquals(0, rtree.size()) + } + + @Test + fun `test exact same bounding boxes`() { + val rtree = Rtree() + val aabb = AABB(0f, 0f, 10f, 10f) + + // Insert several elements with the same bbox + rtree.insert(aabb, "item1") + rtree.insert(aabb, "item2") + rtree.insert(aabb, "item3") + + val results = rtree.search(aabb) + assertEquals(3, results.size) + assertTrue(results.containsAll(listOf("item1", "item2", "item3"))) + } + + @Test + fun `test boundary intersection`() { + val rtree = Rtree() + + // Create elements that touch boundaries + rtree.insert(AABB(0f, 0f, 10f, 10f), "item1") + rtree.insert(AABB(10f, 0f, 20f, 10f), "item2") // Touches on X + rtree.insert(AABB(0f, 10f, 10f, 20f), "item3") // Touches on Y + rtree.insert(AABB(10f, 10f, 20f, 20f), "item4") // Touches on corner + + // Search in area including all elements + val results = rtree.search(AABB(0f, 0f, 20f, 20f)) + assertEquals(4, results.size) + } + + @Test + fun `test deep tree structure`() { + val rtree = Rtree() + for (i in 0..500) { + rtree.insert(AABB(i.toFloat(), i.toFloat(), (i + 10).toFloat(), (i + 10).toFloat()), "item$i") + } + val results = rtree.search(AABB(5f, 5f, 15f, 15f)) + assertTrue(results.size >= 15) + for (i in 0..14) { + assertTrue(results.contains("item$i")) + } + } + + @Test + fun `test degenerate cases`() { + val rtree = Rtree() + for (i in 0..5) { + rtree.insert(AABB(0f, i * 10f, 10f, (i + 1) * 10f), "xline$i") + } + for (i in 0..5) { + rtree.insert(AABB(i * 10f, 0f, (i + 1) * 10f, 10f), "yline$i") + } + val xResults = rtree.search(AABB(0f, 0f, 10f, 60f)) + assertTrue(xResults.size >= 7) + for (i in 0..5) { + assertTrue(xResults.contains("xline$i")) + } + val yResults = rtree.search(AABB(0f, 0f, 60f, 10f)) + assertTrue(yResults.size >= 7) + for (i in 0..5) { + assertTrue(yResults.contains("yline$i")) + } + } + + @Test + fun `test node removal and rebalancing`() { + val rtree = Rtree(maxEntries = 4, minEntries = 2) + + // Fill the tree + for (i in 0..5) { + rtree.insert(AABB(i * 10f, i * 10f, (i + 1) * 10f, (i + 1) * 10f), "item$i") + } + + // Check that all elements are accessible + val results = rtree.search(AABB(0f, 0f, 60f, 60f)) + assertEquals(6, results.size) + + // Check search in separate areas + val areaResults = rtree.search(AABB(0f, 0f, 15f, 15f)) + assertEquals(2, areaResults.size) + assertTrue(areaResults.contains("item0")) + assertTrue(areaResults.contains("item1")) + } + + @Test + fun `test non intersecting search`() { + val rtree = Rtree() + + // Insert elements in a specific area + rtree.insert(AABB(0f, 0f, 10f, 10f), "item1") + rtree.insert(AABB(20f, 20f, 30f, 30f), "item2") + + // Search in area not intersecting with any element + val results = rtree.search(AABB(40f, 40f, 50f, 50f)) + assertTrue(results.isEmpty()) + + // Search in partially intersecting area + val partialResults = rtree.search(AABB(5f, 5f, 25f, 25f)) + assertTrue(partialResults.isNotEmpty()) + } + + @Test + fun `test large dataset performance`() { + val rtree = Rtree(maxEntries = 16, minEntries = 4) // Reduce node size for better balance + val numElements = 50_000 + val gridSize = 100 // Grid size 100x100 + + // List to store all elements for naive search + val allElements = mutableListOf>() + + println("Starting insertion of $numElements elements...") + + // Create and shuffle indices for random insertion order + val indices = (0 until numElements).shuffled() + + // Insert elements in random order with varying sizes + for (i in indices) { + val x = (i % gridSize) * 10 + val y = (i / gridSize) * 10 + // Add random element size from 5 to 15 + val size = 5 + (i % 11) + val bounds = AABB( + x.toFloat(), + y.toFloat(), + (x + size).toFloat(), + (y + size).toFloat() + ) + rtree.insert(bounds, "item$i") + allElements.add(bounds to "item$i") + + if (i % 1000 == 0) { + println("Inserted $i elements, current depth: ${rtree.depth()}") + println("Sample bounds for item$i: $bounds") + } + } + + println("Final tree depth: ${rtree.depth()}") + println("Tree size: ${rtree.size()}") + + // Check size + assertEquals(numElements, rtree.size()) + + // Check depth - for 50,000 elements with maxEntries=16 we expect depth around 4-5 + val expectedMinDepth = 4 + val expectedMaxDepth = 6 + assertTrue( + rtree.depth() in expectedMinDepth..expectedMaxDepth, + "Tree depth should be between $expectedMinDepth and $expectedMaxDepth for $numElements elements with maxEntries=16" + ) + + // Search in different areas + val searchAreas = listOf( + // Exact matches with elements + AABB(0f, 0f, 10f, 10f), // Should find elements at (0,0) + AABB(500f, 500f, 510f, 510f), // Should find elements at (500,500) + AABB(990f, 990f, 1000f, 1000f), // Should find elements at (990,990) + + // Large search areas + AABB(0f, 0f, 100f, 100f), // Should find all elements in 100x100 square + AABB(400f, 400f, 600f, 600f), // Should find all elements in 200x200 square + + // Random search areas + AABB(123f, 456f, 133f, 466f), // Random area + AABB(789f, 321f, 799f, 331f), // Random area + + // Edge cases + AABB(-10f, -10f, 0f, 0f), // Area outside grid + AABB(1000f, 1000f, 1010f, 1010f) // Area outside grid + ) + + // Create results string + val results = StringBuilder() + var totalRtreeTime = 0L + var totalNaiveTime = 0L + var totalElementsFound = 0 + + for (area in searchAreas) { + println("\nSearching in area: $area") + + // R-tree search + val rtreeStartTime = Clock.System.now() + val rtreeResults = rtree.search(area) + val rtreeEndTime = Clock.System.now() + val rtreeTime = rtreeEndTime - rtreeStartTime + totalRtreeTime += rtreeTime.inWholeMilliseconds + + // Naive search + val naiveStartTime = Clock.System.now() + val naiveResults = allElements + .filter { (bounds, _) -> bounds.intersects(area) } + .map { it.second } + .toSet() + val naiveEndTime = Clock.System.now() + val naiveTime = naiveEndTime - naiveStartTime + totalNaiveTime += naiveTime.inWholeMilliseconds + totalElementsFound += naiveResults.size + + // Print detailed results + println("R-tree found ${rtreeResults.size} elements") + println("Naive search found ${naiveResults.size} elements") + if (rtreeResults.isNotEmpty()) { + println("Sample R-tree results: ${rtreeResults.take(5)}") + } + if (naiveResults.isNotEmpty()) { + println("Sample naive results: ${naiveResults.take(5)}") + } + + results.append("\nSearch in area $area:\n") + results.append("R-tree: found ${rtreeResults.size} elements in ${rtreeTime.inWholeMilliseconds}ms\n") + results.append("Naive: found ${naiveResults.size} elements in ${naiveTime.inWholeMilliseconds}ms\n") + results.append("Speedup: ${naiveTime.inWholeMilliseconds.toDouble() / rtreeTime.inWholeMilliseconds}x\n") + + // Verify correctness + assertEquals(naiveResults, rtreeResults, "Search results should match") + + // Verify that R-tree is indeed faster + assertTrue(rtreeTime < naiveTime, "R-tree should be faster than naive search") + } + + // Print summary statistics + results.append("\nSummary Statistics:\n") + results.append("Total R-tree time: ${totalRtreeTime}ms\n") + results.append("Total Naive time: ${totalNaiveTime}ms\n") + results.append("Average speedup: ${totalNaiveTime.toDouble() / totalRtreeTime}x\n") + results.append("Tree depth: ${rtree.depth()}\n") + results.append("Total elements: ${rtree.size()}\n") + results.append("Average elements per search: ${totalElementsFound.toDouble() / searchAreas.size}\n") + + println(results.toString()) + } + + @Test + fun `test simple dataset`() { + val rtree = Rtree() + + // Create a simple grid of elements + for (i in 0..2) { + for (j in 0..2) { + val bounds = AABB( + i * 10f, j * 10f, + (i + 1) * 10f, (j + 1) * 10f + ) + val item = "item_${i}_${j}" + rtree.insert(bounds, item) + println("Inserted $item with bounds: $bounds") + } + } + + // Test search in different areas + val testAreas = listOf( + // Search in the middle element (should find all 9 elements due to boundary touching) + AABB(10f, 10f, 20f, 20f) to listOf( + "item_0_0", "item_0_1", "item_0_2", + "item_1_0", "item_1_1", "item_1_2", + "item_2_0", "item_2_1", "item_2_2" + ), + // Search in the corner (should find 4 elements due to boundary touching) + AABB(0f, 0f, 10f, 10f) to listOf( + "item_0_0", "item_0_1", + "item_1_0", "item_1_1" + ), + // Search overlapping two elements (should find 4 elements due to boundary touching) + AABB(5f, 5f, 15f, 15f) to listOf( + "item_0_0", "item_0_1", + "item_1_0", "item_1_1" + ), + // Search outside (should find item_2_2 because it touches at point (30,30)) + AABB(30f, 30f, 40f, 40f) to listOf("item_2_2") + ) + + for ((area, expectedItems) in testAreas) { + println("\nSearching in area: $area") + val results = rtree.search(area) + println("Found items: $results") + println("Expected items: $expectedItems") + + assertEquals( + expectedItems.toSet(), + results, + "Search in area $area should find exactly $expectedItems" + ) + } + } +} \ No newline at end of file diff --git a/library/src/desktopMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.desktop.kt b/library/src/desktopMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.desktop.kt new file mode 100644 index 00000000..ef9447d3 --- /dev/null +++ b/library/src/desktopMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.desktop.kt @@ -0,0 +1,33 @@ +package ovh.plrapps.mapcompose.vector.data + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.Image +import org.jetbrains.skia.ImageInfo + +internal actual fun imageBitmapFromArgb(argb: IntArray, width: Int, height: Int): ImageBitmap { + val info = ImageInfo( + width = width, + height = height, + colorType = ColorType.BGRA_8888, + alphaType = ColorAlphaType.PREMUL + ) + + val bytes = ByteArray(width * height * 4) + var j = 0 + for (px in argb) { + bytes[j++] = (px and 0xFF).toByte() // B + bytes[j++] = ((px ushr 8) and 0xFF).toByte() // G + bytes[j++] = ((px ushr 16) and 0xFF).toByte() // R + bytes[j++] = ((px ushr 24) and 0xFF).toByte() // A + } + + val skiaImage = Image.makeRaster(info, bytes, width * 4) + return skiaImage.toComposeImageBitmap() +} + +internal actual fun byteArrayToImageBitmap(bytes: ByteArray): ImageBitmap { + return Image.makeFromEncoded(bytes).toComposeImageBitmap() +} \ No newline at end of file diff --git a/library/src/desktopMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.desktop.kt b/library/src/desktopMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.desktop.kt new file mode 100644 index 00000000..22921acf --- /dev/null +++ b/library/src/desktopMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.desktop.kt @@ -0,0 +1,15 @@ +package ovh.plrapps.mapcompose.vector.data.extension + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asSkiaBitmap +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Image + +actual fun ImageBitmap.toBytes(): ByteArray? { + val skiaBmp = this.asSkiaBitmap() + + return Image + .makeFromBitmap(skiaBmp) + .encodeToData(EncodedImageFormat.PNG) + ?.bytes +} \ No newline at end of file diff --git a/library/src/iosMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.ios.kt b/library/src/iosMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.ios.kt new file mode 100644 index 00000000..ef9447d3 --- /dev/null +++ b/library/src/iosMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.ios.kt @@ -0,0 +1,33 @@ +package ovh.plrapps.mapcompose.vector.data + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.Image +import org.jetbrains.skia.ImageInfo + +internal actual fun imageBitmapFromArgb(argb: IntArray, width: Int, height: Int): ImageBitmap { + val info = ImageInfo( + width = width, + height = height, + colorType = ColorType.BGRA_8888, + alphaType = ColorAlphaType.PREMUL + ) + + val bytes = ByteArray(width * height * 4) + var j = 0 + for (px in argb) { + bytes[j++] = (px and 0xFF).toByte() // B + bytes[j++] = ((px ushr 8) and 0xFF).toByte() // G + bytes[j++] = ((px ushr 16) and 0xFF).toByte() // R + bytes[j++] = ((px ushr 24) and 0xFF).toByte() // A + } + + val skiaImage = Image.makeRaster(info, bytes, width * 4) + return skiaImage.toComposeImageBitmap() +} + +internal actual fun byteArrayToImageBitmap(bytes: ByteArray): ImageBitmap { + return Image.makeFromEncoded(bytes).toComposeImageBitmap() +} \ No newline at end of file diff --git a/library/src/iosMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.ios.kt b/library/src/iosMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.ios.kt new file mode 100644 index 00000000..22921acf --- /dev/null +++ b/library/src/iosMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.ios.kt @@ -0,0 +1,15 @@ +package ovh.plrapps.mapcompose.vector.data.extension + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asSkiaBitmap +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Image + +actual fun ImageBitmap.toBytes(): ByteArray? { + val skiaBmp = this.asSkiaBitmap() + + return Image + .makeFromBitmap(skiaBmp) + .encodeToData(EncodedImageFormat.PNG) + ?.bytes +} \ No newline at end of file diff --git a/library/src/wasmJsMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.wasmJs.kt b/library/src/wasmJsMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.wasmJs.kt new file mode 100644 index 00000000..ef9447d3 --- /dev/null +++ b/library/src/wasmJsMain/kotlin/ovh/plrapps/mapcompose/vector/data/SpriteManager.wasmJs.kt @@ -0,0 +1,33 @@ +package ovh.plrapps.mapcompose.vector.data + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.Image +import org.jetbrains.skia.ImageInfo + +internal actual fun imageBitmapFromArgb(argb: IntArray, width: Int, height: Int): ImageBitmap { + val info = ImageInfo( + width = width, + height = height, + colorType = ColorType.BGRA_8888, + alphaType = ColorAlphaType.PREMUL + ) + + val bytes = ByteArray(width * height * 4) + var j = 0 + for (px in argb) { + bytes[j++] = (px and 0xFF).toByte() // B + bytes[j++] = ((px ushr 8) and 0xFF).toByte() // G + bytes[j++] = ((px ushr 16) and 0xFF).toByte() // R + bytes[j++] = ((px ushr 24) and 0xFF).toByte() // A + } + + val skiaImage = Image.makeRaster(info, bytes, width * 4) + return skiaImage.toComposeImageBitmap() +} + +internal actual fun byteArrayToImageBitmap(bytes: ByteArray): ImageBitmap { + return Image.makeFromEncoded(bytes).toComposeImageBitmap() +} \ No newline at end of file diff --git a/library/src/wasmJsMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.wasmJs.kt b/library/src/wasmJsMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.wasmJs.kt new file mode 100644 index 00000000..22921acf --- /dev/null +++ b/library/src/wasmJsMain/kotlin/ovh/plrapps/mapcompose/vector/data/extension/ImageBitmap.wasmJs.kt @@ -0,0 +1,15 @@ +package ovh.plrapps.mapcompose.vector.data.extension + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asSkiaBitmap +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Image + +actual fun ImageBitmap.toBytes(): ByteArray? { + val skiaBmp = this.asSkiaBitmap() + + return Image + .makeFromBitmap(skiaBmp) + .encodeToData(EncodedImageFormat.PNG) + ?.bytes +} \ No newline at end of file diff --git a/library/tools/fetch-expression-fixtures.sh b/library/tools/fetch-expression-fixtures.sh new file mode 100755 index 00000000..acc5bdbd --- /dev/null +++ b/library/tools/fetch-expression-fixtures.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Vendors MapLibre's expression conformance suite into a single bundled test resource. +# +# The upstream suite is 577 tiny `test.json` files. Compose resources are read one path at a time, +# so they are flattened into one JSON object keyed "/" -- one resource read instead +# of 577, and no generated path list to keep in sync. +# +# Usage: library/tools/fetch-expression-fixtures.sh [] +# +set -euo pipefail + +REF="${1:-d881eef3be6a3602ff3434e1d9a20067b3fe1a31}" +REPO="https://github.com/maplibre/maplibre-style-spec.git" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT_DIR="$SCRIPT_DIR/../src/commonTest/composeResources/files" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +echo "Cloning $REPO @ $REF" +git clone --quiet --depth 1 "$REPO" "$WORK/mls" +git -C "$WORK/mls" fetch --quiet --depth 1 origin "$REF" 2>/dev/null || true +git -C "$WORK/mls" checkout --quiet "$REF" 2>/dev/null || echo " (using default branch; $REF not fetchable as a ref)" + +SHA="$(git -C "$WORK/mls" rev-parse HEAD)" + +python3 - "$WORK/mls" "$OUT_DIR" "$SHA" <<'PY' +import json, os, sys + +root, out_dir, sha = sys.argv[1], sys.argv[2], sys.argv[3] +tests_dir = os.path.join(root, "test/integration/expression/tests") + +bundle = {} +for dirpath, _, filenames in os.walk(tests_dir): + if "test.json" not in filenames: + continue + key = os.path.relpath(dirpath, tests_dir) + with open(os.path.join(dirpath, "test.json")) as f: + bundle[key] = json.load(f) + +os.makedirs(out_dir, exist_ok=True) +payload = {"upstreamCommit": sha, "tests": dict(sorted(bundle.items()))} +with open(os.path.join(out_dir, "expression-tests.json"), "w") as f: + json.dump(payload, f, separators=(",", ":"), sort_keys=False) + +print(f"Wrote {len(bundle)} fixtures from {sha}") +PY + +cp "$WORK/mls/LICENSE.txt" "$OUT_DIR/expression-tests-LICENSE.txt" +echo "Copied upstream LICENSE (BSD-3-Clause) alongside the fixtures"