diff --git a/.gitignore b/.gitignore index 62984142..94487d6c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ publish-mavenCentral.sh compile_and_install_demo_release.sh .kotlin +/demo/maplibreDebugApp/cache/ diff --git a/build.gradle.kts b/build.gradle.kts index 2b709a74..e0bbfa96 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,5 +6,6 @@ plugins { alias(libs.plugins.jetbrainsCompose) apply false alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinx.serialization).apply(false) alias(libs.plugins.vanniktechPublish) apply false } diff --git a/demo/composeApp/build.gradle.kts b/demo/composeApp/build.gradle.kts index bc1d7eac..1bb2b4dd 100644 --- a/demo/composeApp/build.gradle.kts +++ b/demo/composeApp/build.gradle.kts @@ -25,7 +25,8 @@ kotlin { "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-Xopt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi", "-Xannotation-default-target=param-property", - "-Xcontext-parameters" + "-Xcontext-parameters", + "-Xexpect-actual-classes" ) } } 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..7d47a12b --- /dev/null +++ b/demo/composeApp/src/androidMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.android.kt @@ -0,0 +1,15 @@ +package ovh.plrapps.mapcompose.demo.ui.screens + +import androidx.compose.runtime.Composable +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import ovh.plrapps.mapcompose.demo.viewmodels.VectorDemoVM + +actual object VectorDemo : Screen { + @Composable + override fun Content() { + val screenModel = rememberScreenModel { VectorDemoVM() } + + 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..eef5eaa5 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.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 329086d8..46433f60 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 @@ -14,6 +14,7 @@ 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.VectorDemo import ovh.plrapps.mapcompose.demo.ui.screens.VisibleAreaPaddingDemo const val HOME = "home" @@ -74,6 +75,10 @@ enum class MainDestinations() { MARKERS_LAZY_LOADING{ override val title = "Markers lazy loading" override val screen = MarkersLazyLoadingDemo + }, + VECTOR_DEMO { + override val title = "Vector tile demo" + override val screen = VectorDemo }; abstract val title: String 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..1971cef2 --- /dev/null +++ b/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.kt @@ -0,0 +1,17 @@ +package ovh.plrapps.mapcompose.demo.ui.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import cafe.adriel.voyager.core.screen.Screen +import ovh.plrapps.mapcompose.demo.viewmodels.VectorDemoVM +import ovh.plrapps.mapcompose.ui.MapUI + +expect object VectorDemo : Screen + +@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/VectorDemoVM.kt b/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/VectorDemoVM.kt new file mode 100644 index 00000000..c1ae8563 --- /dev/null +++ b/demo/composeApp/src/commonMain/kotlin/ovh/plrapps/mapcompose/demo/viewmodels/VectorDemoVM.kt @@ -0,0 +1,57 @@ +package ovh.plrapps.mapcompose.demo.viewmodels + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import kotlinx.coroutines.launch +import kotlinx.io.RawSource +import ovh.plrapps.mapcompose.api.addVectorLayer +import ovh.plrapps.mapcompose.api.scale +import ovh.plrapps.mapcompose.api.shouldLoopScale +import ovh.plrapps.mapcompose.core.VectorTileStreamProvider +import ovh.plrapps.mapcompose.ui.state.MapState +import kotlin.math.pow + +/** + * Shows how MapCompose behaves with remote HTTP tiles. + */ +class VectorDemoVM : ScreenModel { + 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 + ).apply { + screenModelScope.launch { + addVectorLayer(vectorTileStreamProvider) + } + 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/VectorDemo.desktop.kt b/demo/composeApp/src/desktopMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.desktop.kt new file mode 100644 index 00000000..7d6dcad5 --- /dev/null +++ b/demo/composeApp/src/desktopMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.desktop.kt @@ -0,0 +1,18 @@ +package ovh.plrapps.mapcompose.demo.ui.screens + +import androidx.compose.runtime.Composable +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import ovh.plrapps.mapcompose.demo.ui.MapWithZoomControl +import ovh.plrapps.mapcompose.demo.viewmodels.VectorDemoVM + +actual object VectorDemo : Screen { + @Composable + override fun Content() { + val screenModel = rememberScreenModel { VectorDemoVM() } + + MapWithZoomControl(state = screenModel.state) { + VectorCommonUi(screenModel) + } + } +} \ 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..eef5eaa5 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.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/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..7d47a12b --- /dev/null +++ b/demo/composeApp/src/iosMain/kotlin/ovh/plrapps/mapcompose/demo/ui/screens/VectorDemo.ios.kt @@ -0,0 +1,15 @@ +package ovh.plrapps.mapcompose.demo.ui.screens + +import androidx.compose.runtime.Composable +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import ovh.plrapps.mapcompose.demo.viewmodels.VectorDemoVM + +actual object VectorDemo : Screen { + @Composable + override fun Content() { + val screenModel = rememberScreenModel { VectorDemoVM() } + + VectorCommonUi(screenModel) + } +} \ 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..8a2030e7 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.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/iosApp/iosApp.xcodeproj/project.pbxproj b/demo/iosApp/iosApp.xcodeproj/project.pbxproj index 4206a4be..17e6c742 100644 --- a/demo/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/demo/iosApp/iosApp.xcodeproj/project.pbxproj @@ -17,7 +17,7 @@ 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; - 7555FF7B242A565900829871 /* MapComposeKMP.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MapComposeKMP.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7555FF7B242A565900829871 /* MapComposeMP.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MapComposeMP.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; @@ -62,7 +62,7 @@ 7555FF7C242A565900829871 /* Products */ = { isa = PBXGroup; children = ( - 7555FF7B242A565900829871 /* MapComposeKMP.app */, + 7555FF7B242A565900829871 /* MapComposeMP.app */, ); name = Products; sourceTree = ""; @@ -107,7 +107,7 @@ packageProductDependencies = ( ); productName = iosApp; - productReference = 7555FF7B242A565900829871 /* MapComposeKMP.app */; + productReference = 7555FF7B242A565900829871 /* MapComposeMP.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -316,7 +316,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = 75AUC96H82; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(SRCROOT)/../../mapcompose/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", @@ -334,6 +334,7 @@ composeApp, ); PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = ovh.plrapps.mapcompose.demo; PRODUCT_NAME = "${APP_NAME}"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; @@ -348,7 +349,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = 75AUC96H82; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(SRCROOT)/../../mapcompose/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", @@ -366,6 +367,7 @@ composeApp, ); PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = ovh.plrapps.mapcompose.demo; PRODUCT_NAME = "${APP_NAME}"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; diff --git a/demo/iosApp/iosApp/Info.plist b/demo/iosApp/iosApp/Info.plist index 412e3781..aaf5e182 100644 --- a/demo/iosApp/iosApp/Info.plist +++ b/demo/iosApp/iosApp/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -20,8 +22,6 @@ 1 LSRequiresIPhoneOS - CADisableMinimumFrameDurationOnPhone - UIApplicationSceneManifest UIApplicationSupportsMultipleScenes 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.properties b/gradle.properties index 7971b8de..4b91e17c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,6 +11,7 @@ android.useAndroidX=true #MPP kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.mpp.enableCInteropCommonization=true - +kotlin.native.ignoreDisabledTargets=true +kotlin.mpp.androidGradlePluginCompatibility.nowarn=true #Development development=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ccf8f144..c0c51b26 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,17 +14,21 @@ androidx-test-junit = "1.3.0" androidx-navigation = "2.9.5" compose = "1.9.3" compose-plugin = "1.9.1" +androidx-lifecycle = "2.8.4" junit = "4.13.2" -kotlin = "2.2.20" +kotlin = "2.2.21" coroutines = "1.10.2" skia = "0.9.22.2" # should be the exact same version compose.ui depends on. See https://mvnrepository.com/artifact/androidx.compose.ui/ui ktor = "3.3.1" voyager = "1.0.1" kotlinx-io = "0.8.0" +lazytable = "1.10.0" +pbandk = "0.16.0" +slf4jSimple = "2.0.12" +kotlinx-datetime = "0.6.2" +kotlinx-serialization = "1.8.1" [libraries] -kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } @@ -38,17 +42,40 @@ androidx-navigation = { module = "androidx.navigation:navigation-compose", versi compose-ui-util = { group = "androidx.compose.ui", name = "ui-util", version.ref = "compose" } 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" } +androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +lazytable = { module = "io.github.oleksandrbalan:lazytable", version.ref = "lazytable" } +skia = { module = "org.jetbrains.skiko:skiko", version.ref = "skia" } +slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4jSimple" } +voyager-navigation = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } +voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" } + +# kotlin std kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" } -skia = { module = "org.jetbrains.skiko:skiko", version.ref = "skia" } -ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +# maplibre-rasterizer +pbandk-runtime = { group = "pro.streem.pbandk", name = "pbandk-runtime", version.ref = "pbandk" } + +# ktor ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } -voyager-navigation = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } -voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } +ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" } +ktor-client-winhttp = { module = "io.ktor:ktor-client-winhttp", version.ref = "ktor" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -56,4 +83,5 @@ androidLibrary = { id = "com.android.library", version.ref = "agp" } jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -vanniktechPublish = { id = "com.vanniktech.maven.publish", version = "0.34.0" } \ No newline at end of file +vanniktechPublish = { id = "com.vanniktech.maven.publish", version = "0.34.0" } +kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 2e141c32..f05dde33 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -1,6 +1,8 @@ -@file:OptIn(ExperimentalKotlinGradlePluginApi::class) +@file:OptIn(ExperimentalKotlinGradlePluginApi::class, ExperimentalWasmDsl::class) +import org.jetbrains.compose.ExperimentalComposeLibrary import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -9,6 +11,7 @@ plugins { alias(libs.plugins.jetbrainsCompose) alias(libs.plugins.composeCompiler) alias(libs.plugins.vanniktechPublish) + alias(libs.plugins.kotlinx.serialization) } kotlin { @@ -75,16 +78,19 @@ kotlin { implementation(libs.kotlinx.coroutines) implementation(libs.skia) api(libs.kotlinx.io.core) + implementation(libs.pbandk.runtime) + implementation(libs.kotlinx.serialization.json) } desktopMain.dependencies { implementation(compose.desktop.currentOs) - } - iosMain.dependencies { - } commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) + implementation(libs.kotlinx.datetime) + + @OptIn(ExperimentalComposeLibrary::class) + implementation(compose.uiTest) } } } @@ -113,6 +119,16 @@ android { dependencies { debugImplementation(libs.compose.ui.tooling) } + + testOptions { + unitTests { + all { + // Excluded ui tests because of issues documented on + // https://kotlinlang.org/docs/multiplatform/compose-test.html#write-and-run-common-tests + it.exclude("**/mapcompose/vector/**") + } + } + } } task("testClasses") 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..8c32cc2b 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.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/core/VectorTileStreamProvider.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/core/VectorTileStreamProvider.kt new file mode 100644 index 00000000..4ee4abe0 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/core/VectorTileStreamProvider.kt @@ -0,0 +1,41 @@ +package ovh.plrapps.mapcompose.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/ui/MapUI.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/MapUI.kt index a258c8b1..46fa7b03 100644 --- a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/MapUI.kt +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/MapUI.kt @@ -2,16 +2,20 @@ package ovh.plrapps.mapcompose.ui import androidx.compose.foundation.background import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.key 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.markers.MarkerComposer import ovh.plrapps.mapcompose.ui.paths.PathComposer import ovh.plrapps.mapcompose.ui.state.MapState +import ovh.plrapps.mapcompose.ui.symbols.SymbolComposer import ovh.plrapps.mapcompose.ui.view.TileCanvas @Composable @@ -21,6 +25,7 @@ fun MapUI( content: @Composable () -> Unit = {} ) { val zoomPRState = state.zoomPanRotateState + val symbolState = state.symbolState val markerState = state.markerRenderState val pathState = state.pathState @@ -28,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( @@ -48,6 +61,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 1f6dbc49..9bc3a411 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 @@ -61,6 +63,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, @@ -71,6 +74,7 @@ class MapState( ) { zoomPanRotateState.scale } + internal val tileCanvasState = TileCanvasState( scope, tileSize, @@ -98,6 +102,8 @@ class MapState( applyLateInitialValues(initialValues) } internal val densityState = MutableStateFlow(null) + internal val fontFamilyResolverState = MutableStateFlow(null) + internal val textMeasurerState = MutableStateFlow(null) /** * Cancels all internal tasks. @@ -163,6 +169,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 { @@ -324,4 +338,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/SymbolState.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/state/SymbolState.kt new file mode 100644 index 00000000..1d7435d9 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/state/SymbolState.kt @@ -0,0 +1,10 @@ +package ovh.plrapps.mapcompose.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()) +} 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 75d74cf9..75b8db21 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 @@ -43,6 +43,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) @@ -455,3 +459,4 @@ internal class TileCanvasState( internal expect fun Tile.sendToRecycle(recycleChannel: Channel) internal expect fun Tile.performRecycle() + diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/symbols/SymbolComposer.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/symbols/SymbolComposer.kt new file mode 100644 index 00000000..7a0cab00 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/ui/symbols/SymbolComposer.kt @@ -0,0 +1,55 @@ +package ovh.plrapps.mapcompose.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.ui.state.SymbolState +import ovh.plrapps.mapcompose.ui.state.ZoomPanRotateState +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 (symbol in symbolState.symbols) { + val canvasX = (symbol.global.x * zoomPRState.fullWidth * zoomPRState.scale - x0) + val canvasY = (symbol.global.y * zoomPRState.fullHeight * zoomPRState.scale - y0) + + val size = symbol.getInPixels() + val offsetX = size.width * symbol.align.x + val offsetY = size.height * symbol.align.y + + withTransform({ + translate(left = (canvasX + offsetX).toFloat(), top = (canvasY + offsetY).toFloat()) + }) { + symbol.draw(this) + } + } + } + } +} 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..258f25ce --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/core/VectorLayer.kt @@ -0,0 +1,137 @@ +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.core.TileStreamProvider +import ovh.plrapps.mapcompose.core.VectorTileStreamProvider +import ovh.plrapps.mapcompose.core.Viewport +import ovh.plrapps.mapcompose.core.VisibleTiles +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) + } + + val configuration = getMapLibreConfiguration( + style = style?.buffered()?.readString() ?: "", + 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 density = mapState.densityState.value ?: return@collectLatest + val tilePx = with(density) { 256.dp.toPx() }.toInt() + + 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 + ) + } + } + } + + private fun listenForViewportUpdates() { + var lastViewport: Viewport? = null + fun updateViewportInfo(visibleTiles: VisibleTiles, viewport: Viewport) { + viewportInfoFlow.value = ViewportInfo( + matrix = visibleTiles.tileMatrix, + size = IntSize( + width = (viewport.right - viewport.left), + height = (viewport.bottom - viewport.top), + ), + angleRad = viewport.angleRad, + pitch = 0f, + zoom = visibleTiles.level + ) + } + + mapState.addViewportChangeListener { viewport -> + val visibleTiles = visibleTiles.value + if (visibleTiles != null) { + lastViewport = viewport + } + } + + scope.launch { + visibleTiles.collect { visibleTiles -> + visibleTiles ?: return@collect + val viewport = lastViewport ?: return@collect + updateViewportInfo(visibleTiles, viewport) + } + } + } +} \ 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..9ff7a019 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/core/VectorRasterizer.kt @@ -0,0 +1,342 @@ +package ovh.plrapps.mapcompose.vector.core + +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.Symbol +import ovh.plrapps.mapcompose.vector.renderer.SymbolsProducer +import ovh.plrapps.mapcompose.vector.renderer.TileRenderer +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 pbandk.decodeFromByteArray +import kotlin.collections.component1 +import kotlin.collections.component2 + +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? +) { + private val tileCache = LruCache(maxSize = 100) + private val byteCache = LruCache(maxSize = 100) + private val pathCache = LruCache(maxSize = 200) + private val propertyCache = LruCache>(maxSize = 500) + private val mutex = Mutex() + + 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) + + val imageBitmap = ImageBitmap(tileSize, tileSize) + val canvas = Canvas(imageBitmap) + val drawScope = CanvasDrawScope() + val tileRenderer = TileRenderer( + configuration = configuration, + pathCache = pathCache, + propertyCache = propertyCache, + mutex = mutex + ) + + drawScope.draw( + density = density, + layoutDirection = LayoutDirection.Ltr, + canvas = canvas, + size = Size(tileSize.toFloat(), tileSize.toFloat()) + ) { + for (styleLayer in configuration.style.layers) { + val tileKey = styleLayer.source.takeIf { !it.isNullOrBlank() }?.let { styleLayerSourceName -> + getTileKey(styleLayerSourceName, z, x, y) + } + val tile: Tile? = tileKey?.let { key -> + mutex.withLock { + tileCache.get(key) + } ?: styleLayer.source + ?.let { sourceName -> pbfList[sourceName] } + ?.let { bytes -> decodePBFFromByteArray(bytes) } + ?.also { tile -> + mutex.withLock { + tileCache.put(key, tile) + } + } + } + + 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) + mutex.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 response = withContext(IODispatcher) { + getTileStream(url, row, col, zoomLvl) + } + + // Check again after network request + currentCoroutineContext().ensureActive() + + if (response == null) { + return Result.failure(LoadTileException("fetch error")) + } + val result = response.buffered().use { bufferedSource -> + bufferedSource.readByteArray() + } + mutex.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, + propertyCache = propertyCache, + mutex = mutex + ) + + 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() + // Calculate overlay sizes based on tileMatrix + val minY = viewport.tileMatrix.keys.min() + val maxY = viewport.tileMatrix.keys.max() + +// println("viewport zoom = ${viewport.zoom} | zoom = $z") + // For each tile in the viewport + for (y in minY..maxY) { + for (x in viewport.tileMatrix[y] ?: emptyList()) { + // 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) + } + + // draw symbols for this tile + for (styleLayer in configuration.style.layers) { + if (styleLayer !is SymbolLayer) continue + + val tile: Tile? = + styleLayer.source.takeIf { !it.isNullOrBlank() }?.let { styleLayerSourceName -> + val key = getTileKey(styleLayerSourceName, z.toInt(), x, y) + mutex.withLock { + tileCache.get(key) + } ?: styleLayerSourceName + .let { sourceName -> pbfList[sourceName] } + ?.let { bytes -> decodePBFFromByteArray(bytes) } + ?.also { tile -> + mutex.withLock { + tileCache.put(key, tile) + } + } + } + if (tile == null) continue + + val tileLayer = tile.layers.find { it.name == styleLayer.sourceLayer } + if (tileLayer == null) continue + + + symbolsProducer.produce( + tile = tile, + styleLayer = styleLayer, + zoom = z, + canvasSize = tileSize, + actualZoom = z, + tileX = x, + tileY = y, + density = density, + ).let { + symbols.addAll(it) + } + } + } + } + + Result.success(symbols) + } + + fun updateSymbols(nextSymbols: List, state: MapState) { + checkIndexErrors(nextSymbols) + println("xxxxx number of symbols ${nextSymbols.size}") + state.symbolState.symbols = nextSymbols + } + + 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})") + } + } + } +} + +/** + * 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, +) + +class LoadTileException(msg: String) : Exception(msg) \ 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..a5f7d448 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/MapLibreConfiguration.kt @@ -0,0 +1,11 @@ +package ovh.plrapps.mapcompose.vector.data + +import ovh.plrapps.mapcompose.vector.spec.style.MapLibreStyle + +data class MapLibreConfiguration( + val style: MapLibreStyle, + val tileSources: Map, + val spriteManager: SpriteManager?, + val collisionDetectionEnabled: Boolean = true, + val lang: LanguageCode? = LanguageCode.English +) 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..99748a83 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/data/getMapLibreConfiguration.kt @@ -0,0 +1,65 @@ +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.tilejson.TileJson + +suspend fun getMapLibreConfiguration( + style: String, + pixelRatio: Int = 1, + loadResource: suspend (String) -> RawSource? +): Result { + try { + val style = json.decodeFromString(MapLibreStyle.serializer(), style) + 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 + )) + + } 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..03e87de1 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/BackgroundLayerPainter.kt @@ -0,0 +1,30 @@ +package ovh.plrapps.mapcompose.vector.renderer + +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 + +class BackgroundLayerPainter : BaseLayerPainter() { + override suspend fun paint( + canvas: DrawScope, + feature: Tile.Feature, + style: BackgroundLayer, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: Map?, + actualZoom: Double, + featureKey: String? + ) { + val paint = style.paint ?: return + + val backgroundColor = paint.backgroundColor?.process(featureProperties, zoom) ?: Color.White + val opacity = paint.backgroundOpacity?.process(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..f23a68d8 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/BaseLayerPainter.kt @@ -0,0 +1,42 @@ +package ovh.plrapps.mapcompose.vector.renderer + +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: Map?, + 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..f5e5de53 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/BaseRenderer.kt @@ -0,0 +1,64 @@ +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 kotlinx.coroutines.sync.withLock + +abstract class BaseRenderer( + private val configuration: MapLibreConfiguration, +) { + + fun shouldRenderFeature( + feature: Tile.Feature, + tileLayer: Tile.Layer, + styleLayer: Layer, + zoom: Double, + featureProperties: Map? = null + ): Boolean { + val filter = styleLayer.filter + if (filter == null) { + return true + } + val properties = featureProperties ?: extractFeatureProperties(feature, tileLayer) + return filter.process( + properties, + zoom = zoom.toInt().toDouble() + ) + } + + 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..maxZoom + } + + 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] + props[key] = + value.stringValue ?: value.floatValue ?: value.doubleValue ?: value.intValue ?: value.uintValue + ?: value.sintValue ?: value.boolValue + } + } + + props["\$type"] = when (feature.type) { + Tile.GeomType.LINESTRING -> "LineString" + Tile.GeomType.POINT -> "Point" + Tile.GeomType.POLYGON -> "Polygon" + Tile.GeomType.UNKNOWN -> "Unknown" + is Tile.GeomType.UNRECOGNIZED -> "Unrecognized" + null -> "" + } + return props + } +} \ No newline at end of file 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..b5cf51c8 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/CircleLayerPainter.kt @@ -0,0 +1,48 @@ +package ovh.plrapps.mapcompose.vector.renderer + +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 + +class CircleLayerPainter : BaseLayerPainter() { + override suspend fun paint( + canvas: DrawScope, + feature: Tile.Feature, + style: CircleLayer, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: Map?, + 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?.process(featureProperties, zoom) ?: Color.Black + val circleOpacity = paint.circleOpacity?.process(featureProperties, zoom)?.toFloat() ?: 1f + val circleRadius = paint.circleRadius?.process(featureProperties, zoom) ?: 5f + val circleStrokeWidth = paint.circleStrokeWidth?.process(featureProperties, zoom)?.toFloat() ?: 0f + val circleStrokeColor = paint.circleStrokeColor?.process(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..8d944dd7 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/FillExtrusionPainter.kt @@ -0,0 +1,25 @@ +package ovh.plrapps.mapcompose.vector.renderer + +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: Map?, + 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..800b51f5 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/FillLayerPainter.kt @@ -0,0 +1,99 @@ +package ovh.plrapps.mapcompose.vector.renderer + +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.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: Map?, + 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?.process(featureProperties, actualZoom) ?: Color.Transparent + val fillOpacity = paint.fillOpacity?.process(featureProperties, actualZoom)?.toFloat() ?: 1f + val fillOutlineColor = paint.fillOutlineColor?.process(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 = 1f, + 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..d6c74ac1 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/HeatmapLayerPainter.kt @@ -0,0 +1,25 @@ +package ovh.plrapps.mapcompose.vector.renderer + +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: Map?, + 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..2ee1c3e7 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/HillshadeLayerPainter.kt @@ -0,0 +1,25 @@ +package ovh.plrapps.mapcompose.vector.renderer + +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: Map?, + 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..4eaf4449 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/LineLayerPainter.kt @@ -0,0 +1,336 @@ +package ovh.plrapps.mapcompose.vector.renderer + +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.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: Map?, + 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?.process(featureProperties = featureProperties, zoom = actualZoom) ?: Color.Black + val lineOpacity = paint.lineOpacity?.process(featureProperties, actualZoom)?.toFloat() ?: 1f + val lineWidth = paint.lineWidth?.process(featureProperties, actualZoom)?.toFloat()?.let { it*canvas.density } ?: 1f + val lineGapWidth = paint.lineGapWidth?.process(featureProperties, actualZoom)?.toFloat() ?: 0f + val lineBlur = paint.lineBlur?.process(featureProperties, actualZoom)?.toFloat() ?: 0f + val lineOffset = paint.lineOffset?.process(featureProperties, actualZoom)?.toFloat() ?: 0f + val lineTranslate = + paint.lineTranslate?.process(featureProperties, actualZoom)?.map { it.toFloat() } ?: listOf(0f, 0f) + val lineTranslateAnchor = paint.lineTranslateAnchor?.process(featureProperties, actualZoom) ?: "map" + val lineCap = getLineCap(layout, actualZoom, featureProperties) + val lineJoin = getLineJoin(layout, actualZoom, featureProperties) + val lineMiterLimit = layout?.lineMiterLimit?.process(featureProperties, actualZoom)?.toFloat() ?: 2f + val lineRoundLimit = layout?.lineRoundLimit?.process(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: Map?): StrokeCap { + return when (layout?.lineCap?.process(featureProperties = featureProperties, zoom = actualZoom) ?: "butt") { + "butt" -> StrokeCap.Butt + "round" -> StrokeCap.Round + "square" -> StrokeCap.Square + else -> StrokeCap.Butt + } + } + + private fun getLineJoin( + layout: LineLayout?, + actualZoom: Double, + featureProperties: Map? + ): StrokeJoin { + return when (layout?.lineJoin?.process(featureProperties = featureProperties, zoom = actualZoom) ?: "miter") { + "bevel" -> StrokeJoin.Bevel + "round" -> StrokeJoin.Round + "miter" -> StrokeJoin.Miter + else -> StrokeJoin.Miter + } + } + + private fun getLineDashArray( + paint: LinePaint, + actualZoom: Double, + featureProperties: Map? + ): DoubleArray? { + return paint.lineDasharray?.process(featureProperties = 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..b5bb39f1 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/RasterLayerPainter.kt @@ -0,0 +1,24 @@ +package ovh.plrapps.mapcompose.vector.renderer + +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: Map?, + 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..83da2b0d --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/SkyLayerPainter.kt @@ -0,0 +1,24 @@ +package ovh.plrapps.mapcompose.vector.renderer + +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: Map?, + 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..fd1eb3a9 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/SymbolLayerPainter.kt @@ -0,0 +1,1041 @@ +package ovh.plrapps.mapcompose.vector.renderer + +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.spec.Tile +import ovh.plrapps.mapcompose.vector.spec.style.SymbolLayer +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? +) + +sealed class Symbol( + val id: String, + val global: Point, + val placement: CompoundLabelPlacement, + val align: Offset = IN_CENTER, +) { + class Sprite( + id: String, + global: Point, + placement: CompoundLabelPlacement, + val value: ImageBitmap, + ) : Symbol(id, global, placement) + + class Text( + id: String, + global: Point, + placement: CompoundLabelPlacement, + val value: TextLayoutResult, + ) : Symbol(id, global, placement) + + 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 + ) : Symbol(id, global, placement, align) + + 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() + + /** + * 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 Regex("\\{([^}]+)\\}").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: Map? + ): String { + return if (input.firstOrNull() == '{') { + val matchResult = regexForSubProcess.find(input) + if (matchResult != null) { + val key = matchResult.groupValues[1] + val propValue = featureProperties?.get(key)?.toString() ?: "" + input.replace("{$key}", propValue) + } else { + input + } + } else { + input + } + } + + private fun produceSprite( + placement: SymbolPlacement, + style: SymbolLayer, + featureProperties: Map?, + actualZoom: Double, + zoom: Double, + id: String, + canvasSize: Int, + tileX: Int, + tileY: Int, + density: Density, + ): Symbol? { + val spriteManager = spriteManager ?: return null + val paint = style.paint ?: return null + val layout = style.layout ?: return null + + val spriteId = + layout.iconImage?.process(featureProperties, actualZoom)?.let { subProcess(it, featureProperties) } + ?: return null + val spriteInfo = spriteManager.getSpriteInfo(spriteId) + if (spriteInfo == null) { +// println("spriteInfo == null for spriteId $spriteId") + return null + } + + val iconScale: Float = layout.iconSize?.process(featureProperties, actualZoom)?.toFloat() ?: DEFAULT_ICON_SCALE + val iconColor = paint.iconColor?.process(featureProperties, actualZoom) + val iconHaloColor = paint.iconHaloColor?.process(featureProperties, actualZoom) + val iconOpacity = paint.iconOpacity?.process(featureProperties, actualZoom)?.toFloat() ?: 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 + + // Use double for exact calculations, then convert to float + val leftBound = (spritePosition.x.toDouble() - size.width.toDouble() / 2.0).toFloat() + val topBound = (spritePosition.y.toDouble() - size.height.toDouble() / 2.0).toFloat() + val rightBound = (spritePosition.x.toDouble() + size.width.toDouble() / 2.0).toFloat() + val bottomBound = + (spritePosition.y.toDouble() + size.height.toDouble() / 2.0).toFloat() + + val bounds = Rect( + left = leftBound, + top = topBound, + right = rightBound, + bottom = bottomBound + ) + + return LabelPlacement( + text = "sprite_$spriteId", + position = ObbPoint( + spritePosition.x, + spritePosition.y + ), + angle = placement.angle, + bounds = bounds, + obb = OBB( + ObbPoint(spritePosition.x, spritePosition.y), + ovh.plrapps.mapcompose.vector.utils.obb.Size(size.width.toFloat(), size.height.toFloat()), + placement.angle + ), + priority = layout.symbolZOrder?.process(featureProperties, actualZoom)?.toInt() ?: 0, + allowOverlap = layout.iconAllowOverlap?.process(featureProperties, actualZoom) ?: false, + ignorePlacement = layout.iconIgnorePlacement?.process(featureProperties, actualZoom) ?: false + ).let { + Symbol.Sprite( + id = id, + global = Point(globalX, globalY), + placement = CompoundLabelPlacement(it, null), + value = sprite + ) + } + } + + /** + * 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: Map?, + actualZoom: Double, + zoom: Double, + canvasSize: Int, + tileX: Int, + tileY: Int, + density: Density, + ): 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?.process(featureProperties, actualZoom)?.let { subProcess(it, featureProperties) } + ?: return null + val spriteInfo = spriteManager.getSpriteInfo(spriteId) ?: return null + + val iconScale: Float = layout.iconSize?.process(featureProperties, actualZoom)?.toFloat() ?: DEFAULT_ICON_SCALE + val iconColor = paint.iconColor?.process(featureProperties, actualZoom) + val iconHaloColor = paint.iconHaloColor?.process(featureProperties, actualZoom) + val iconOpacity = paint.iconOpacity?.process(featureProperties, actualZoom)?.toFloat() ?: 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?.process(featureProperties, actualZoom) ?: return null + val textField = substituteTemplate(templateTextField, featureProperties) + if (textField.isBlank() || textField.length > 256) return null + + val fontSize = (layout.textSize?.process(featureProperties, actualZoom)?.toFloat() ?: 16f) + val textMaxWidth = + layout.textMaxWidth?.process(featureProperties, actualZoom)?.toFloat() ?: Float.POSITIVE_INFINITY + + val textColor = paint.textColor?.process(featureProperties, actualZoom) ?: Color.Black + val textOpacity = paint.textOpacity?.process(featureProperties, actualZoom)?.toFloat() ?: 1f + if (textOpacity == 0f) return null + + val textHaloColor = paint.textHaloColor?.process(featureProperties, actualZoom) ?: Color.White + val textHaloWidth = (paint.textHaloWidth?.process(featureProperties, actualZoom)?.toFloat() ?: 0f) + + 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 + 2f)).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 spriteLabelPlacement = LabelPlacement( + text = "sprite_$spriteId", + position = spritePosition, + angle = 0f, + bounds = Rect( + left = spritePosition.x - spriteSize.width / 2f, + top = spritePosition.y - spriteSize.height / 2f, + right = spritePosition.x + spriteSize.width / 2f, + bottom = spritePosition.y + spriteSize.height / 2f + ), + obb = OBB( + spritePosition, + ovh.plrapps.mapcompose.vector.utils.obb.Size(spriteSize.width.toFloat(), spriteSize.height.toFloat()), + 0f + ), + priority = layout.symbolZOrder?.process(featureProperties, actualZoom)?.toInt() ?: 0, + allowOverlap = layout.iconAllowOverlap?.process(featureProperties, actualZoom) ?: false, + ignorePlacement = layout.iconIgnorePlacement?.process(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 = 0f, + bounds = Rect( + left = textPosition.x - textSize.width / 2f, + top = textPosition.y - textSize.height / 2f, + right = textPosition.x + textSize.width / 2f, + bottom = textPosition.y + textSize.height / 2f + ), + obb = OBB( + textPosition, + ovh.plrapps.mapcompose.vector.utils.obb.Size(textSize.width.toFloat(), textSize.height.toFloat()), + 0f + ), + priority = layout.symbolZOrder?.process(featureProperties, actualZoom)?.toInt() ?: 0, + allowOverlap = layout.textAllowOverlap?.process(featureProperties, actualZoom) ?: false, + ignorePlacement = layout.textIgnorePlacement?.process(featureProperties, actualZoom) ?: false + ) + + val offsetY = -((spriteSize.height.toFloat() / 2f) / totalHeight) + + val centerOffset = Offset( + x = -0.5f, + y = offsetY + ) + + 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 + ) + } + + private suspend fun produceText( + placement: SymbolPlacement, + style: SymbolLayer, + featureProperties: Map?, + actualZoom: Double, + zoom: Double, + lineStrings: List>>? = null, + id: String, + canvasSize: Int, + tileX: Int, + tileY: Int, + density: Density, + ): Symbol? { + val textMeasurer = textMeasurerState.value ?: return null + + val layout = style.layout ?: return null + val paint = style.paint ?: return null + + val templateTextField = layout.textField?.process(featureProperties, actualZoom) ?: return null + val textField = substituteTemplate(templateTextField, featureProperties) + if (textField.isBlank() || textField.length > 256) { + return null + } + + val fontSize = (layout.textSize?.process(featureProperties, actualZoom)?.toFloat() ?: 16f) + val textMaxWidth = + layout.textMaxWidth?.process(featureProperties, actualZoom)?.toFloat() ?: Float.POSITIVE_INFINITY + + val textColor = paint.textColor?.process(featureProperties, actualZoom) ?: Color.Black + val textOpacity = paint.textOpacity?.process(featureProperties, actualZoom)?.toFloat() ?: 1f + if (textOpacity == 0f) { + return null + } + + val textHaloColor = paint.textHaloColor?.process(featureProperties, actualZoom) ?: Color.White + val textHaloWidth = (paint.textHaloWidth?.process(featureProperties, actualZoom)?.toFloat() ?: 0f) + val offsetArr = layout.textOffset?.process(featureProperties, actualZoom) ?: listOf(0.0, 0.0) + val anchor = layout.textAnchor?.process(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 + ) + } 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 + ) + } + } + + private fun produceLineText( + id: String, + lineStrings: List>>, + layout: SymbolLayout, + featureProperties: Map?, + actualZoom: Double, + textWidth: Float, + dx: Float, + dy: Float, + tileX: Int, + tileY: Int, + textHeight: Float, + textField: String, + zoom: Double, + canvasSize: Int, + textLayoutResult: TextLayoutResult, + ): Symbol? { + val symbolSpacing = layout.symbolSpacing?.process(featureProperties, actualZoom)?.toFloat() ?: 250f + 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.Companion.calculatePlacements(line, textWidth, symbolSpacing) + 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() + val topBound = (y.toDouble() - textHeight.toDouble() / 2.0).toFloat() + val rightBound = (x.toDouble() + textWidth.toDouble() / 2.0).toFloat() + val bottomBound = (y.toDouble() + textHeight.toDouble() / 2.0).toFloat() + + // 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" + + return LabelPlacement( + text = textField, + position = ObbPoint(x, y), + angle = displayAngle, + bounds = Rect( + left = leftBound, + top = topBound, + right = rightBound, + bottom = bottomBound + ), + obb = OBB( + ObbPoint(x, y), + ovh.plrapps.mapcompose.vector.utils.obb.Size(textWidth, textHeight), + displayAngle + ), + priority = layout.symbolZOrder?.process(featureProperties, actualZoom)?.toInt() ?: 0, + allowOverlap = layout.textAllowOverlap?.process(featureProperties, actualZoom) ?: false, + ignorePlacement = layout.textIgnorePlacement?.process(featureProperties, actualZoom) + ?: false + ).let { labelPlacement -> + Symbol.Text( + id = stableId, + global = Point(globalX, globalY), + placement = CompoundLabelPlacement( + spritePlacement = labelPlacement, + textPlacement = labelPlacement + ), + value = textLayoutResult + ) + } + } 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() + val topBound = (y.toDouble() - textHeight.toDouble() / 2.0).toFloat() + val rightBound = (x.toDouble() + textWidth.toDouble() / 2.0).toFloat() + val bottomBound = (y.toDouble() + textHeight.toDouble() / 2.0).toFloat() + return LabelPlacement( + text = textField, + position = ObbPoint(x, y), + angle = displayAngle, + bounds = Rect( + left = leftBound, + top = topBound, + right = rightBound, + bottom = bottomBound + ), + obb = OBB( + ObbPoint(x, y), + ovh.plrapps.mapcompose.vector.utils.obb.Size(textWidth, textHeight), + displayAngle + ), + priority = layout.symbolZOrder?.process(featureProperties, actualZoom)?.toInt() ?: 0, + allowOverlap = layout.textAllowOverlap?.process(featureProperties, actualZoom) ?: false, + ignorePlacement = layout.textIgnorePlacement?.process(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 // Correct placement for text + ), + value = textLayoutResult + ) + } + } 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: Map?, + actualZoom: Double, + textLayoutResult: TextLayoutResult, + textField: String, + density: Density, + id: String + ): 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 leftBound = (textPosition.x.toDouble() - textWidth.toDouble() / 2.0).toFloat() + val topBound = (textPosition.y.toDouble() - textHeight.toDouble() / 2.0).toFloat() + val rightBound = (textPosition.x.toDouble() + textWidth.toDouble() / 2.0).toFloat() + val bottomBound = (textPosition.y.toDouble() + textHeight.toDouble() / 2.0).toFloat() + + val bounds = Rect( + left = leftBound, + top = topBound, + right = rightBound, + bottom = bottomBound + ) + + return LabelPlacement( + text = textField, + position = ObbPoint(textPosition.x, textPosition.y), + angle = placement.angle, + bounds = bounds, + obb = OBB( + center = ObbPoint(textPosition.x, textPosition.y), + size = ovh.plrapps.mapcompose.vector.utils.obb.Size(textWidth, textHeight), + rotation = placement.angle + ), + priority = layout.symbolZOrder?.process(featureProperties, actualZoom)?.toInt() ?: 0, + allowOverlap = layout.textAllowOverlap?.process(featureProperties, actualZoom) ?: false, + ignorePlacement = layout.textIgnorePlacement?.process(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" + + Symbol.Text( + id = stableId, + global = Point(globalX, globalY), + placement = CompoundLabelPlacement( + spritePlacement = labelPlacement, + textPlacement = labelPlacement // Correct placement for text + ), + value = textLayoutResult, + ) + } + } catch (_: IllegalArgumentException) { + return null + } + } + + suspend fun produceSymbol( + feature: Tile.Feature, + style: SymbolLayer, + canvasSize: Int, + extent: Int, + zoom: Double, + featureProperties: Map?, + actualZoom: Double, + id: String, + tileX: Int = 0, + tileY: Int = 0, + density: Density, + ): 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?.process(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 + )?.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 + )?.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, + )?.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..7ff1bd91 --- /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 androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.unit.Density +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +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 propertyCache: LruCache>, + private val mutex: Mutex +) : BaseRenderer(configuration = configuration) { + + val symbolsPainter = SymbolLayerPainter( + textMeasurerState = textMeasurer, + spriteManager = configuration.spriteManager, + configuration = configuration, + pathCache = pathCache, + mutex = mutex + ) + + suspend fun produce( + tile: Tile?, + styleLayer: SymbolLayer, + zoom: Double, + canvasSize: Int, + actualZoom: Double, + tileX: Int = 0, + tileY: Int = 0, + density: Density, + ): 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() + + 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) { + mutex.withLock { + propertyCache.get(propertyKey) + } ?: extractFeatureProperties(feature, tileLayer).also { + mutex.withLock { + propertyCache.put(propertyKey, it) + } + } + } else { + extractFeatureProperties(feature, tileLayer) + } + + 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 + ).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..5db76459 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/TileRenderer.kt @@ -0,0 +1,213 @@ +package ovh.plrapps.mapcompose.vector.renderer + +import kotlinx.coroutines.sync.withLock +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 propertyCache: LruCache>, + private val mutex: Mutex +) : 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, mutex) + is HeatmapLayer -> HeatmapLayerPainter() + is HillshadeLayer -> HillshadeLayerPainter() + is LineLayer -> LineLayerPainter(pathCache, mutex) + is RasterLayer -> RasterLayerPainter() + is SkyLayer -> SkyLayerPainter() + } + } + + 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) { + mutex.withLock { + propertyCache.get(propertyKey) + } ?: extractFeatureProperties(feature, tileLayer).also { + mutex.withLock { + propertyCache.put(propertyKey, it) + } + } + } else { + extractFeatureProperties(feature, tileLayer) + } + + 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..99991a6d --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/CollisionDetector.kt @@ -0,0 +1,73 @@ +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() + + /** + * Checks if the given element will collide with already placed ones, + * WITHOUT registering it in the tree + */ + fun wouldCollide(label: LabelPlacement): Boolean { + if (label.ignorePlacement) { + return false + } + if (label.allowOverlap) { + return false + } + + val candidates = rtree.search(label.obb.getAABB()) + + for (existing in candidates) { + if (existing.allowOverlap) { + continue + } + if (label.obb.intersects(existing.obb)) { + if (label.priority <= existing.priority) { + return true // There will be a collision + } + } + } + + return false // There will be no collision + } + + fun tryPlaceLabel(label: LabelPlacement): Boolean { + if (label.ignorePlacement) { + return true + } + if (label.allowOverlap) { + rtree.insert(label.obb.getAABB(), label) + return true + } + + val candidates = rtree.search(label.obb.getAABB()) + + for (existing in candidates) { + if (existing.allowOverlap) { + continue + } + if (label.obb.intersects(existing.obb)) { + if (label.priority <= existing.priority) { + return false + } + } + } + + rtree.insert(label.obb.getAABB(), label) + return true + } + + 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..f0df28c5 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/LabelPlacement.kt @@ -0,0 +1,16 @@ +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 priority: Int, + val allowOverlap: Boolean, + 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..a305cec2 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/LineLabelPlacement.kt @@ -0,0 +1,49 @@ +package ovh.plrapps.mapcompose.vector.renderer.collision + +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.sqrt + +class LineLabelPlacement { + companion object { + fun calculatePlacements( + points: List>, + textWidth: Float, + spacing: Float + ): List, Float>> { + if (points.size < 2) return emptyList() + + val placements = mutableListOf, Float>>() + var distance = 0f + + for (i in 0 until points.size - 1) { + val p1 = points[i] + val p2 = points[i + 1] + + val dx = p2.first - p1.first + val dy = p2.second - p1.second + val segmentLength = sqrt(dx * dx + dy * dy) + + if (segmentLength == 0f) continue + + val angle = atan2(dy, dx) * 180f / PI.toFloat() + + // We start from half the interval from the beginning of the segment + var currentDistance = spacing / 2 + + while (currentDistance + textWidth / 2 <= segmentLength) { + val t = currentDistance / segmentLength + val x = p1.first + t * dx + val y = p1.second + t * dy + + placements.add((x to y) to angle) + currentDistance += spacing + } + + distance += segmentLength + } + + return placements + } + } +} \ No newline at end of file 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/Filter.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/Filter.kt new file mode 100644 index 00000000..277e4a21 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/Filter.kt @@ -0,0 +1,385 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +import kotlinx.serialization.* +import kotlinx.serialization.json.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* + +fun equalsWithNumberCoercion(a: Any?, b: Any?): Boolean { + return when { + a is Number && b is Number -> a.toDouble() == b.toDouble() + else -> a == b + } +} + +@Serializable(with = FilterSerializer::class) +sealed class Filter { + companion object { + const val TYPE_PROPERTY = "\$type" + } + + abstract fun process(featureProperties: Map, zoom: Double): Boolean + + @Serializable + @SerialName("all") + data class All(val filters: List) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + return filters.all { it.process(featureProperties, zoom) } + } + } + + @Serializable + @SerialName("any") + data class AnyOf(val filters: List) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + return filters.any { it.process(featureProperties, zoom) } + } + } + + @Serializable + @SerialName("none") + data class None(val filters: List) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + return filters.none { it.process(featureProperties, zoom) } + } + } + + @Serializable + @SerialName("==") + data class Eq(val left: FilterOperand, val right: FilterOperand) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return equalsWithNumberCoercion(leftValue, rightValue) + } + } + + @Serializable + @SerialName("!=") + data class Neq(val left: FilterOperand, val right: FilterOperand) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return !equalsWithNumberCoercion(leftValue, rightValue) + } + } + + @Serializable + @SerialName(">") + data class Gt(val left: FilterOperand, val right: FilterOperand) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return when { + leftValue is Number && rightValue is Number -> leftValue.toDouble() > rightValue.toDouble() + leftValue is String && rightValue is String -> leftValue > rightValue + else -> false + } + } + } + + @Serializable + @SerialName(">=") + data class Gte(val left: FilterOperand, val right: FilterOperand) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return when { + leftValue is Number && rightValue is Number -> leftValue.toDouble() >= rightValue.toDouble() + leftValue is String && rightValue is String -> leftValue >= rightValue + else -> false + } + } + } + + @Serializable + @SerialName("<") + data class Lt(val left: FilterOperand, val right: FilterOperand) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return when { + leftValue is Number && rightValue is Number -> leftValue.toDouble() < rightValue.toDouble() + leftValue is String && rightValue is String -> leftValue < rightValue + else -> false + } + } + } + + @Serializable + @SerialName("<=") + data class Lte(val left: FilterOperand, val right: FilterOperand) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return when { + leftValue is Number && rightValue is Number -> leftValue.toDouble() <= rightValue.toDouble() + leftValue is String && rightValue is String -> leftValue <= rightValue + else -> false + } + } + } + + @Serializable + @SerialName("in") + data class InList(val key: FilterOperand, val values: List) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + val keyValue = key.evaluate(featureProperties, zoom) + return values.any { it.evaluate(featureProperties, zoom) == keyValue } + } + } + + @Serializable + @SerialName("!in") + data class NotInList(val key: FilterOperand, val values: List) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + val keyValue = key.evaluate(featureProperties, zoom) + return values.none { it.evaluate(featureProperties, zoom) == keyValue } + } + } + + @Serializable + @SerialName("has") + data class Has(val key: String) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + return featureProperties.containsKey(key) + } + } + + @Serializable + @SerialName("!has") + data class NotHas(val key: String) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + return !featureProperties.containsKey(key) + } + } + + @Serializable + data class ZoomGreaterThan( + @Contextual val value: Number + ) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + return zoom > value.toDouble() + } + } + + @Serializable + data class ZoomGreaterThanOrEqual( + @Contextual val value: Number + ) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + return zoom >= value.toDouble() + } + } + + @Serializable + data class ZoomLessThan( + @Contextual val value: Number + ) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + return zoom < value.toDouble() + } + } + + @Serializable + data class ZoomLessThanOrEqual( + @Contextual val value: Number + ) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + return zoom <= value.toDouble() + } + } + + @Serializable + data class Type( + val value: String + ) : Filter() { + override fun process(featureProperties: Map, zoom: Double): Boolean { + return featureProperties["type"] as? String == value + } + } +} + +@Serializable +sealed class FilterOperand { + abstract fun evaluate(featureProperties: Map, zoom: Double): Any? + + @Serializable + @SerialName("get") + data class Get(val property: String) : FilterOperand() { + override fun evaluate(featureProperties: Map, zoom: Double): Any? { + return featureProperties[property] + } + } + + @Serializable + @SerialName("to-boolean") + data class ToBoolean(val operand: FilterOperand) : FilterOperand() { + override fun evaluate(featureProperties: Map, zoom: Double): Any? { + val value = operand.evaluate(featureProperties, zoom) + return when (value) { + null -> false + is Boolean -> value + is String -> value.isNotEmpty() + is Number -> value.toDouble() != 0.0 + is Collection<*> -> value.isNotEmpty() + else -> true + } + } + } + + @Serializable + data class Literal(@Contextual val value: Any) : FilterOperand() { + override fun evaluate(featureProperties: Map, zoom: Double): Any? { + return value + } + } +} + +@OptIn(ExperimentalSerializationApi::class) +@Serializer(forClass = Any::class) +object AnySerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("Any", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Any) { + when (value) { + is String -> encoder.encodeString(value) + is Number -> encoder.encodeDouble(value.toDouble()) + is Boolean -> encoder.encodeBoolean(value) + else -> throw SerializationException("Unsupported type: ${value::class}") + } + } + + override fun deserialize(decoder: Decoder): Any { + return when (val value = decoder.decodeString()) { + "true" -> true + "false" -> false + else -> { + value.toDoubleOrNull() ?: value + } + } + } +} + +object FilterSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Filter") + + override fun deserialize(decoder: Decoder): Filter { + val input = decoder as? JsonDecoder ?: error("FilterSerializer only works with JSON") + val json = input.decodeJsonElement() + val arr = json.jsonArray + if (arr.isEmpty()) error("Filter array is empty") + val op = arr[0].jsonPrimitive.content + fun getOperand(el: JsonElement, isKey: Boolean = false): FilterOperand = + when { + el is JsonArray && el.size == 2 && el[0].jsonPrimitive.content == "get" -> + FilterOperand.Get(el[1].jsonPrimitive.content) + el is JsonArray && el.size == 2 && el[0].jsonPrimitive.content == "to-boolean" -> + FilterOperand.ToBoolean(getOperand(el[1])) + el is JsonPrimitive && el.isString && isKey -> + FilterOperand.Get(el.content) + el is JsonPrimitive && el.isString && !isKey -> + FilterOperand.Literal(el.content) + else -> + FilterOperand.Literal(input.json.decodeFromJsonElement(AnySerializer, el)) + } + return when (op) { + "all" -> Filter.All(arr.drop(1).map { input.json.decodeFromJsonElement(FilterSerializer, it) }) + "any" -> Filter.AnyOf(arr.drop(1).map { input.json.decodeFromJsonElement(FilterSerializer, it) }) + "none" -> Filter.None(arr.drop(1).map { input.json.decodeFromJsonElement(FilterSerializer, it) }) + "==" -> Filter.Eq(getOperand(arr[1], isKey = true), getOperand(arr[2])) + "!=" -> Filter.Neq(getOperand(arr[1], isKey = true), getOperand(arr[2])) + ">" -> Filter.Gt(getOperand(arr[1], isKey = true), getOperand(arr[2])) + ">=" -> Filter.Gte(getOperand(arr[1], isKey = true), getOperand(arr[2])) + "<" -> Filter.Lt(getOperand(arr[1], isKey = true), getOperand(arr[2])) + "<=" -> Filter.Lte(getOperand(arr[1], isKey = true), getOperand(arr[2])) + "in" -> Filter.InList(getOperand(arr[1], isKey = true), arr.drop(2).map { getOperand(it, isKey = false) }) + "!in" -> Filter.NotInList(getOperand(arr[1], isKey = true), arr.drop(2).map { getOperand(it, isKey = false) }) + "has" -> Filter.Has(arr[1].jsonPrimitive.content) + "!has" -> Filter.NotHas(arr[1].jsonPrimitive.content) + "to-boolean" -> Filter.Eq(getOperand(arr[1]), FilterOperand.Literal(true)) + else -> error("Unknown filter operator: $op") + } + } + + override fun serialize(encoder: Encoder, value: Filter) { + val output = encoder as? JsonEncoder ?: error("FilterSerializer only works with JSON") + val arr = when (value) { + is Filter.All -> buildJsonArray { + add(JsonPrimitive("all")) + value.filters.forEach { add(output.json.encodeToJsonElement(FilterSerializer, it)) } + } + is Filter.AnyOf -> buildJsonArray { + add(JsonPrimitive("any")) + value.filters.forEach { add(output.json.encodeToJsonElement(FilterSerializer, it)) } + } + is Filter.None -> buildJsonArray { + add(JsonPrimitive("none")) + value.filters.forEach { add(output.json.encodeToJsonElement(FilterSerializer, it)) } + } + is Filter.Eq -> buildJsonArray { + add(JsonPrimitive("==")) + add(operandToJson(value.left)) + add(operandToJson(value.right)) + } + is Filter.Neq -> buildJsonArray { + add(JsonPrimitive("!=")) + add(operandToJson(value.left)) + add(operandToJson(value.right)) + } + is Filter.Gt -> buildJsonArray { + add(JsonPrimitive(">")) + add(operandToJson(value.left)) + add(operandToJson(value.right)) + } + is Filter.Gte -> buildJsonArray { + add(JsonPrimitive(">=")) + add(operandToJson(value.left)) + add(operandToJson(value.right)) + } + is Filter.Lt -> buildJsonArray { + add(JsonPrimitive("<")) + add(operandToJson(value.left)) + add(operandToJson(value.right)) + } + is Filter.Lte -> buildJsonArray { + add(JsonPrimitive("<=")) + add(operandToJson(value.left)) + add(operandToJson(value.right)) + } + is Filter.InList -> buildJsonArray { + add(JsonPrimitive("in")) + add(operandToJson(value.key)) + value.values.forEach { add(operandToJson(it)) } + } + is Filter.NotInList -> buildJsonArray { + add(JsonPrimitive("!in")) + add(operandToJson(value.key)) + value.values.forEach { add(operandToJson(it)) } + } + is Filter.Has -> buildJsonArray { + add(JsonPrimitive("has")) + add(JsonPrimitive(value.key)) + } + is Filter.NotHas -> buildJsonArray { + add(JsonPrimitive("!has")) + add(JsonPrimitive(value.key)) + } + else -> error("Serialization not implemented for this filter type") + } + output.encodeJsonElement(arr) + } + + private fun operandToJson(op: FilterOperand): JsonElement = when (op) { + is FilterOperand.Get -> buildJsonArray { + add(JsonPrimitive("get")) + add(JsonPrimitive(op.property)) + } + is FilterOperand.ToBoolean -> buildJsonArray { + add(JsonPrimitive("to-boolean")) + add(operandToJson(op.operand)) + } + is FilterOperand.Literal -> Json.encodeToJsonElement(AnySerializer, op.value) + } +} \ 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..f76c8419 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/Layer.kt @@ -0,0 +1,193 @@ +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.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? + abstract val filter: Filter? + 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, + override val filter: Filter? = 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, + override val filter: Filter? = 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, + override val filter: Filter? = 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, + override val filter: Filter? = 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, + override val filter: Filter? = 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, + override val filter: Filter? = 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, + override val filter: Filter? = 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, + override val filter: Filter? = 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, + override val filter: Filter? = 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, + override val filter: Filter? = 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..3c586671 --- /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: Int? = null, + @SerialName("pitch") var pitch: Int? = 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..2bb93bcb --- /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/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/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..792c5ebd --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrValue.kt @@ -0,0 +1,814 @@ +package ovh.plrapps.mapcompose.vector.spec.style.props + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import kotlinx.serialization.Contextual +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.serializers.ExpressionOrValueSerializer +import ovh.plrapps.mapcompose.vector.spec.style.utils.knownExpressions +import kotlin.math.* + +@Serializable(with = ExpressionOrValueSerializer::class) +sealed class ExpressionOrValue { + open val source: String = "" + + data class Value(val value: T, override var source: String = "") : ExpressionOrValue() + data class Expression(val expr: Expr, override var source: String = "") : ExpressionOrValue() + + fun process( + featureProperties: Map? = null, + zoom: Double? = null + ): T? = when (this) { + is Value -> value + is Expression -> expr.evaluate(featureProperties, zoom) + } + + companion object { + fun isExpression(input: JsonElement): Boolean { + return when (input) { + is JsonArray -> { + val head = input.firstOrNull() + head is JsonPrimitive && head.isString && head.content in knownExpressions + } + + is JsonObject -> input.containsKey("stops") || + input.containsKey("type") || + input.containsKey("base") || + input.containsKey("property") + + else -> false + } + } + } +} + +@Serializable +sealed class Expr { + abstract fun evaluate( + featureProperties: Map?, + zoom: Double? + ): T? + + @Serializable + data class Get(val property: Expr<@Contextual Any?>) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): T? { + val key = property.evaluate(featureProperties, zoom) as? String ?: return null + return featureProperties?.get(key) as? T + } + } + + @Serializable + + data class ToNumber(val input: Expr<@Contextual Any?>) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val value = input.evaluate(featureProperties, zoom) + return when (value) { + is Number -> value.toDouble() + is String -> value.toDoubleOrNull() + else -> null + } + } + } + + @Serializable + data class Image(val input: Expr<@Contextual Any?>) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): String? { + val value = input.evaluate(featureProperties, zoom) + return value?.toString() + } + } + + @Serializable + data class Step( + val input: Expr, + val default: Expr, + val stops: List>> + ) : Expr() { + override fun evaluate( + featureProperties: Map?, + zoom: Double? + ): T? { + val inputValue = input.evaluate(featureProperties, zoom) + if (inputValue == null) return default.evaluate(featureProperties, zoom) + + var matched: Expr? = null + for ((stop, valueExpr) in stops) { + if (inputValue < stop) break + matched = valueExpr + } + return (matched ?: default).evaluate(featureProperties, zoom) + } + } + + @Serializable + data class Constant(val value: T) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?) = value + } + + @Serializable + data class Match( + val input: Expr<@Contextual Any?>, + val branches: List, Expr>>, + val elseExpr: Expr + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): T? { + val inputValue = input.evaluate(featureProperties, zoom)?.toString() + for ((values, expr) in branches) { + if (inputValue != null && values.contains(inputValue)) { + return expr.evaluate(featureProperties, zoom) + } + } + return elseExpr.evaluate(featureProperties, zoom) + } + } + + @Serializable + data class ZoomStops(val stops: List>>) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): T? { + val z: Double = zoom ?: return (stops.firstOrNull()?.second?.evaluate(featureProperties, zoom) as T?) + return stops.lastOrNull { it.first <= z }?.second?.evaluate(featureProperties, zoom) as T? + ?: stops.firstOrNull()?.second?.evaluate(featureProperties, zoom) as T? + } + } + + + @Serializable + data class Raw(val json: JsonElement) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?) = null + } + + @Serializable + data class Has(val property: String) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Boolean? { + return featureProperties?.containsKey(property) + } + } + + @Serializable + data class In( + val value: Expr<@Contextual Any?>, + @Contextual + val array: List + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Boolean? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return array.contains(evaluatedValue?.toString()) + } + } + + @Serializable + data class Case( + val conditions: List, Expr>>, + val default: Expr + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): T? { + for ((condition, result) in conditions) { + if (condition.evaluate(featureProperties, zoom) == true) { + return result.evaluate(featureProperties, zoom) + } + } + return default.evaluate(featureProperties, zoom) + } + } + + @Serializable + data class Coalesce(val values: List>) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): T? { + for (expr in values) { + val result = expr.evaluate(featureProperties, zoom) + if (result != null) { + return result + } + } + return null + } + } + + @Serializable + data class At(val index: Int, val array: Expr>) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): T? { + val arrayValue = array.evaluate(featureProperties, zoom) ?: return null + return arrayValue.getOrNull(index) + } + } + + @Serializable + data class Length(val array: Expr>) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Int? { + val arrayValue = array.evaluate(featureProperties, zoom) ?: return null + return arrayValue.size + } + } + + @Serializable + data class Slice(val array: Expr>, val start: Int, val end: Int?) : Expr>() { + override fun evaluate(featureProperties: Map?, zoom: Double?): List? { + val arrayValue = array.evaluate(featureProperties, zoom) ?: return null + val endIndex = end ?: arrayValue.size + return arrayValue.subList(start, endIndex.coerceAtMost(arrayValue.size)) + } + } + + @Serializable + data class NotEquals( + val left: Expr, + val right: Expr + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Boolean? { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return leftValue != rightValue + } + } + + @Serializable + data class Equals( + val left: Expr, + val right: Expr + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Boolean? { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return leftValue == rightValue + } + } + + @Serializable + data class LessThan( + val left: Expr<@Contextual Any?>, + val right: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Boolean? { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return when { + leftValue == null || rightValue == null -> null + leftValue is Number && rightValue is Number -> leftValue.toDouble() < rightValue.toDouble() + leftValue is String && rightValue is String -> leftValue < rightValue + else -> null + } + } + } + + @Serializable + data class LessThanOrEqual( + val left: Expr<@Contextual Any?>, + val right: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Boolean? { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return when { + leftValue == null || rightValue == null -> null + leftValue is Number && rightValue is Number -> leftValue.toDouble() <= rightValue.toDouble() + leftValue is String && rightValue is String -> leftValue <= rightValue + else -> null + } + } + } + + @Serializable + data class GreaterThan( + val left: Expr<@Contextual Any?>, + val right: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Boolean? { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return when { + leftValue == null || rightValue == null -> null + leftValue is Number && rightValue is Number -> leftValue.toDouble() > rightValue.toDouble() + leftValue is String && rightValue is String -> leftValue > rightValue + else -> null + } + } + } + + @Serializable + data class GreaterThanOrEqual( + val left: Expr<@Contextual Any?>, + val right: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Boolean? { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return when { + leftValue == null || rightValue == null -> null + leftValue is Number && rightValue is Number -> leftValue.toDouble() >= rightValue.toDouble() + leftValue is String && rightValue is String -> leftValue >= rightValue + else -> null + } + } + } + + @Serializable + data class All(val conditions: List>) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Boolean? { + if (conditions.isEmpty()) return null + for (condition in conditions) { + val result = condition.evaluate(featureProperties, zoom) + if (result == null || !result) return result + } + return true + } + } + + @Serializable + data class AnyOf(val conditions: List>) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Boolean? { + if (conditions.isEmpty()) return null + for (condition in conditions) { + val result = condition.evaluate(featureProperties, zoom) + if (result == true) return true + if (result == null) return null + } + return false + } + } + + @Serializable + data class Not(val condition: Expr) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Boolean? { + val result = condition.evaluate(featureProperties, zoom) + return result?.not() + } + } + + @Serializable + data class Modulo( + val left: Expr<@Contextual Any?>, + val right: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return when { + leftValue == null || rightValue == null -> null + leftValue is Number && rightValue is Number -> { + val divisor = rightValue.toDouble() + if (divisor == 0.0) null + else leftValue.toDouble() % divisor + } + + else -> null + } + } + } + + @Serializable + data class Power( + val left: Expr<@Contextual Any?>, + val right: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return when { + leftValue == null || rightValue == null -> null + leftValue is Number && rightValue is Number -> { + val base = leftValue.toDouble() + val exponent = rightValue.toDouble() + base.pow(exponent) + } + + else -> null + } + } + } + + @Serializable + data class Abs( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + is Number -> abs(evaluatedValue.toDouble()) + else -> null + } + } + } + + @Serializable + data class Acos( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + is Number -> acos(evaluatedValue.toDouble()) + else -> null + } + } + } + + @Serializable + data class Asin( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + is Number -> asin(evaluatedValue.toDouble()) + else -> null + } + } + } + + @Serializable + data class Atan( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + is Number -> atan(evaluatedValue.toDouble()) + else -> null + } + } + } + + @Serializable + data class Ceil( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + is Number -> ceil(evaluatedValue.toDouble()) + else -> null + } + } + } + + @Serializable + data class Cos( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + is Number -> cos(evaluatedValue.toDouble()) + else -> null + } + } + } + + @Serializable + data class Floor( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + is Number -> floor(evaluatedValue.toDouble()) + else -> null + } + } + } + + @Serializable + data class Ln( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + is Number -> { + val doubleValue = evaluatedValue.toDouble() + if (doubleValue <= 0) null + else ln(doubleValue) + } + + else -> null + } + } + } + + @Serializable + data class Log10( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + is Number -> { + val doubleValue = evaluatedValue.toDouble() + if (doubleValue <= 0) null + else log10(doubleValue) + } + + else -> null + } + } + } + + @Serializable + data class Max( + val left: Expr<@Contextual Any?>, + val right: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return when { + leftValue == null || rightValue == null -> null + leftValue is Number && rightValue is Number -> + max(leftValue.toDouble(), rightValue.toDouble()) + + else -> null + } + } + } + + @Serializable + data class Min( + val left: Expr<@Contextual Any?>, + val right: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val leftValue = left.evaluate(featureProperties, zoom) + val rightValue = right.evaluate(featureProperties, zoom) + return when { + leftValue == null || rightValue == null -> null + leftValue is Number && rightValue is Number -> + min(leftValue.toDouble(), rightValue.toDouble()) + + else -> null + } + } + } + + @Serializable + data class Round( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + is Number -> round(evaluatedValue.toDouble()) + else -> null + } + } + } + + @Serializable + data class Sin( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + is Number -> sin(evaluatedValue.toDouble()) + else -> null + } + } + } + + @Serializable + data class Sqrt( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + is Number -> { + val doubleValue = evaluatedValue.toDouble() + if (doubleValue < 0) null + else sqrt(doubleValue) + } + + else -> null + } + } + } + + @Serializable + data class Tan( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + is Number -> tan(evaluatedValue.toDouble()) + else -> null + } + } + } + + @Serializable + data class Stops(val stops: List>) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): T? { + if (stops.isEmpty()) return null + if (stops.size == 1) return stops.first().second + + val sortedStops = stops.sortedBy { it.first } + val value = zoom ?: return sortedStops.first().second + + return when { + value <= sortedStops.first().first -> sortedStops.first().second + value >= sortedStops.last().first -> sortedStops.last().second + else -> { + val index = sortedStops.indexOfLast { it.first <= value } + if (index < 0 || index == sortedStops.lastIndex) return null + val (lowerStop, lowerValue) = sortedStops[index] + val (upperStop, upperValue) = sortedStops[index + 1] + val t = (value - lowerStop) / (upperStop - lowerStop) + interpolate(t, lowerValue, upperValue) + } + } + } + } + + @Serializable + data class Interpolate( + val interpolation: InterpolationType, + val input: Expr<@Contextual Any?>, + val stops: List>> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): T? { + if (stops.isEmpty()) return null + if (stops.size == 1) return stops.first().second.evaluate(featureProperties, zoom) + + val sortedStops = stops.sortedBy { it.first } + val inputValue = input.evaluate(featureProperties, zoom) as? Double ?: return null + val firstStop = sortedStops.first() + val lastStop = sortedStops.last() + return when { + inputValue <= firstStop.first -> firstStop.second.evaluate(featureProperties, zoom) + inputValue >= lastStop.first -> lastStop.second.evaluate(featureProperties, zoom) + else -> { + val index = sortedStops.indexOfLast { it.first <= inputValue } + if (index < 0 || index == sortedStops.lastIndex) return null + val (lowerStop, lowerExpr) = sortedStops[index] + val (upperStop, upperExpr) = sortedStops[index + 1] + val t = when (interpolation) { + is InterpolationType.Linear -> (inputValue - lowerStop) / (upperStop - lowerStop) + is InterpolationType.Exponential -> { + val base = interpolation.base + if (base == 1.0) { + (inputValue - lowerStop) / (upperStop - lowerStop) + } else { + (base.pow((inputValue - lowerStop) / (upperStop - lowerStop)) - 1) / (base - 1) + } + } + + else -> (inputValue - lowerStop) / (upperStop - lowerStop) // fallback + } + val lowerValue = lowerExpr.evaluate(featureProperties, zoom) ?: return null + val upperValue = upperExpr.evaluate(featureProperties, zoom) ?: return null + interpolate(t, lowerValue, upperValue) + } + } + } + } + + @Serializable + data class Concat( + val left: Expr>, + val right: Expr> + ) : Expr>() { + override fun evaluate(featureProperties: Map?, zoom: Double?): List? { + val leftValue = left.evaluate(featureProperties, zoom) ?: return null + val rightValue = right.evaluate(featureProperties, zoom) ?: return null + return leftValue + rightValue + } + } + + @Serializable + data class IndexOf( + val array: Expr>, + val value: Expr + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Int? { + val arrayValue = array.evaluate(featureProperties, zoom) ?: return null + val searchValue = value.evaluate(featureProperties, zoom) ?: return null + return arrayValue.indexOf(searchValue) + } + } + + @Serializable + data class Downcase( + val value: Expr + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): String? { + val evaluatedValue = value.evaluate(featureProperties, zoom) ?: return null + return evaluatedValue.lowercase() + } + } + + @Serializable + data class Upcase( + val value: Expr + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): String? { + val evaluatedValue = value.evaluate(featureProperties, zoom) ?: return null + return evaluatedValue.uppercase() + } + } + + @Serializable + data class ToBoolean( + val value: Expr<@Contextual Any?> + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Boolean? { + val evaluatedValue = value.evaluate(featureProperties, zoom) + return when (evaluatedValue) { + null -> false + is String -> evaluatedValue.isNotEmpty() + is Number -> evaluatedValue.toDouble() != 0.0 && !evaluatedValue.toDouble().isNaN() + is Boolean -> evaluatedValue + else -> true + } + } + } + + @Serializable + data class ToString( + val value: Expr + ) : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): String? { + val evaluated = value.evaluate(featureProperties, zoom) + return when (evaluated) { + null -> "" + is String -> evaluated + is Boolean -> evaluated.toString() + is Number -> evaluated.toString() + is Color -> "rgba(${(evaluated.red * 255).toInt()},${(evaluated.green * 255).toInt()},${(evaluated.blue * 255).toInt()},${(evaluated.alpha * 100).toInt() / 100.0})" + is Map<*, *> -> { + val entries = evaluated.map { (k, v) -> + "\"${k}\":${ + when (v) { + null -> "null" + is String -> "\"$v\"" + is Number, is Boolean -> v.toString() + is Map<*, *>, is List<*> -> (ToString(Constant(v))).evaluate(null, null) + else -> "\"${v.toString()}\"" + } + }" + } + "{${entries.joinToString(",")}}" + } + + is List<*> -> { + val elements = evaluated.map { v -> + when (v) { + null -> "null" + is String -> "\"$v\"" + is Number, is Boolean -> v.toString() + is Map<*, *>, is List<*> -> (ToString(Constant(v))).evaluate(null, null) + else -> "\"${v.toString()}\"" + } + } + "[${elements.joinToString(",")}]" + } + + else -> evaluated.toString() + } + } + } + + @Serializable + object Zoom : Expr() { + override fun evaluate(featureProperties: Map?, zoom: Double?): Double? = zoom + } + + private fun nearest(value: Double, a: T, b: T): T = + if (abs(value - a.toDouble()) <= abs(value - b.toDouble())) a else b + + // TODO need test + fun interpolate(t: Double, a: T, b: T): T? { + return when { + a is Double && b is Double -> (a * (1 - t) + b * t) as T + a is Float && b is Float -> (a * (1 - t.toFloat()) + b * t.toFloat()) as T + a is Int && b is Int -> (a * (1 - t) + b * t).toInt() as T + a is Number && b is Number -> { + val at = a.toDouble() + val bt = b.toDouble() + (at * (1 - t) + bt * t) as T + } + a is Color && b is Color -> lerp(start = a, stop = b, fraction = t.toFloat()) as T + a is String && b is String -> if (t <= 0.5) a else b + else -> if (t < 0.5) a else b + } + } +} + +@Serializable +sealed class InterpolationType { + @Serializable + object Linear : InterpolationType() + + @Serializable + data class Exponential(val base: Double) : InterpolationType() + + @Serializable + object Cubic : InterpolationType() + + @Serializable + object Step : InterpolationType() +} \ No newline at end of file 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/ExprSerializer.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/ExprSerializer.kt new file mode 100644 index 00000000..535c9cf8 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/ExprSerializer.kt @@ -0,0 +1,1311 @@ +package ovh.plrapps.mapcompose.vector.spec.style.serializers + +import kotlinx.serialization.Contextual +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.Serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.* +import ovh.plrapps.mapcompose.vector.spec.style.props.Expr +import ovh.plrapps.mapcompose.vector.spec.style.props.InterpolationType +import ovh.plrapps.mapcompose.vector.spec.style.utils.isExpr +import ovh.plrapps.mapcompose.vector.spec.style.utils.normalizeLegacyExpression + +object ExprSerializerFactory { + fun create(valueSerializer: KSerializer): KSerializer> { + return ExprSerializer(valueSerializer) + } +} + +@OptIn(ExperimentalSerializationApi::class) +@Serializer(forClass = Expr::class) +class ExprSerializer( + private val valueSerializer: KSerializer +) : KSerializer> { + + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Expr") + + private fun serializeGet(value: Expr.Get, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("get"), + encoder.json.encodeToJsonElement(valueSerializer, (value as T)) + ) + ) + ) + } + + private fun serializeMatch(value: Expr.Match, encoder: JsonEncoder) { + val elements = mutableListOf(JsonPrimitive("match")) + // input + elements.add(serializeExpr(value.input, encoder)) + + for ((values, expr) in value.branches) { + if (values.size == 1) { + elements.add(JsonPrimitive(values[0])) + } else { + elements.add(JsonArray(values.map { JsonPrimitive(it) })) + } + elements.add(serializeExpr(expr, encoder)) + } + // else + elements.add(serializeExpr(value.elseExpr, encoder)) + encoder.encodeJsonElement(JsonArray(elements)) + } + + private fun serializeZoomStops(value: Expr.ZoomStops, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("zoom"), + JsonArray(value.stops.map { (zoom, value) -> + JsonArray( + listOf( + JsonPrimitive(zoom), + encoder.json.encodeToJsonElement(valueSerializer, (value as T)) + ) + ) + }) + ) + ) + ) + } + + private fun serializeConstant(value: Expr.Constant, encoder: JsonEncoder) { + encoder.encodeJsonElement(encoder.json.encodeToJsonElement(valueSerializer, value.value)) + } + + private fun serializeRaw(value: Expr.Raw, encoder: JsonEncoder) { + encoder.encodeJsonElement(value.json) + } + + private fun serializeStops(value: Expr.Stops, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonObject( + mapOf( + "stops" to JsonArray(value.stops.map { (stop, value) -> + JsonArray( + listOf( + JsonPrimitive(stop), + encoder.json.encodeToJsonElement(valueSerializer, value) + ) + ) + }) + ) + ) + ) + } + + private fun serializeHas(value: Expr.Has, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("has"), + JsonPrimitive(value.property) + ) + ) + ) + } + + private fun serializeIn(value: Expr.In, encoder: JsonEncoder) { + val evaluatedValue = value.value.evaluate(null, null) + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("in"), + JsonPrimitive(evaluatedValue?.toString() ?: ""), + JsonArray(value.array.map { JsonPrimitive(it) }) + ) + ) + ) + } + + private fun serializeCase(value: Expr.Case, encoder: JsonEncoder) { + val elements = mutableListOf(JsonPrimitive("case")) + value.conditions.forEach { (condition, result) -> + elements.add(serializeExpr(condition, encoder)) + elements.add(serializeExpr(result, encoder)) + } + elements.add(serializeExpr(value.default, encoder)) + encoder.encodeJsonElement(JsonArray(elements)) + } + + private fun serializeCoalesce(value: Expr.Coalesce, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("coalesce") + ) + value.values.map { expr -> + val evaluatedValue = expr.evaluate(null, null) + when (evaluatedValue) { + null -> JsonNull + is T -> encoder.json.encodeToJsonElement(valueSerializer, evaluatedValue) + else -> JsonPrimitive(evaluatedValue.toString()) + } + }) + ) + } + + private fun serializeInterpolate(value: Expr.Interpolate, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("interpolate"), + JsonArray( + listOf( + JsonPrimitive( + when (value.interpolation) { + is InterpolationType.Linear -> "linear" + is InterpolationType.Exponential -> "exponential" + is InterpolationType.Cubic -> "cubic" + is InterpolationType.Step -> "step" + } + ) + ) + ), + JsonPrimitive(value.input.toString()) + ) + value.stops.flatMap { (stop, expr) -> + listOf( + JsonPrimitive(stop), + serializeExpr(expr, encoder) + ) + }) + ) + } + + private fun serializeAt(value: Expr.At, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("at"), + JsonPrimitive(value.index), + JsonArray((value.array.evaluate(null, null) ?: emptyList()).map { item -> + when (item) { + is T -> encoder.json.encodeToJsonElement(valueSerializer, item) + else -> JsonPrimitive(item.toString()) + } + }) + ) + ) + ) + } + + private fun serializeLength(value: Expr.Length, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("length"), + JsonArray((value.array.evaluate(null, null) ?: emptyList()).map { item -> + when (item) { + is T -> encoder.json.encodeToJsonElement(valueSerializer, item) + else -> JsonPrimitive(item.toString()) + } + }) + ) + ) + ) + } + + private fun serializeSlice(value: Expr.Slice, encoder: JsonEncoder) { + val elements = mutableListOf( + JsonPrimitive("slice"), + JsonArray((value.array.evaluate(null, null) ?: emptyList()).map { item -> + when (item) { + is T -> encoder.json.encodeToJsonElement(valueSerializer, item) + else -> JsonPrimitive(item.toString()) + } + }), + JsonPrimitive(value.start) + ) + if (value.end != null) { + elements.add(JsonPrimitive(value.end)) + } + encoder.encodeJsonElement(JsonArray(elements)) + } + + private fun serializeNotEquals(value: Expr.NotEquals<*, *>, encoder: JsonEncoder) { + val leftElement = when (val left = value.left) { + is Expr.Get<*> -> JsonArray( + listOf( + JsonPrimitive("get"), + encoder.json.encodeToJsonElement(valueSerializer, (value as T)) + ) + ) + + else -> JsonPrimitive(left.evaluate(null, null)?.toString() ?: "") + } + + val rightElement = when (val right = value.right) { + is Expr.Get<*> -> JsonArray( + listOf( + JsonPrimitive("get"), + encoder.json.encodeToJsonElement(valueSerializer, (value as T)) + ) + ) + + else -> JsonPrimitive(right.evaluate(null, null)?.toString() ?: "") + } + + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("!="), + leftElement, + rightElement + ) + ) + ) + } + + private fun serializeEquals(value: Expr.Equals<*, *>, encoder: JsonEncoder) { + val leftElement = when (val left = value.left) { + is Expr.Get<*> -> JsonArray( + listOf( + JsonPrimitive("get"), + encoder.json.encodeToJsonElement(valueSerializer, (value as T)) + ) + ) + + else -> JsonPrimitive(left.evaluate(null, null)?.toString() ?: "") + } + + val rightElement = when (val right = value.right) { + is Expr.Get<*> -> JsonArray( + listOf( + JsonPrimitive("get"), + encoder.json.encodeToJsonElement(valueSerializer, (value as T)) + ) + ) + + else -> JsonPrimitive(right.evaluate(null, null)?.toString() ?: "") + } + + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("=="), + leftElement, + rightElement + ) + ) + ) + } + + private fun serializeExpr(expr: Expr<@Contextual Any?>, jsonEncoder: JsonEncoder): JsonElement { + return when (expr) { + is Expr.Get -> JsonArray( + listOf( + JsonPrimitive("get"), + jsonEncoder.json.encodeToJsonElement(valueSerializer, (expr as T)) + ) + ) + + is Expr.Constant -> when (val value = expr.value) { + is String -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + else -> JsonNull + } + + is Expr.Concat<*> -> JsonArray( + listOf( + JsonPrimitive("concat"), + serializeExpr(expr.left, jsonEncoder), + serializeExpr(expr.right, jsonEncoder) + ) + ) + + else -> JsonNull + } + } + + private fun serializeLessThan(expr: Expr.LessThan<*>, jsonEncoder: JsonEncoder) { + jsonEncoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("<"), + serializeExpr(expr.left, jsonEncoder), + serializeExpr(expr.right, jsonEncoder) + ) + ) + ) + } + + private fun serializeLessThanOrEqual(expr: Expr.LessThanOrEqual<*>, jsonEncoder: JsonEncoder) { + jsonEncoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("<="), + serializeExpr(expr.left, jsonEncoder), + serializeExpr(expr.right, jsonEncoder) + ) + ) + ) + } + + private fun serializeGreaterThan(expr: Expr.GreaterThan<*>, jsonEncoder: JsonEncoder) { + jsonEncoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive(">"), + serializeExpr(expr.left, jsonEncoder), + serializeExpr(expr.right, jsonEncoder) + ) + ) + ) + } + + private fun serializeGreaterThanOrEqual(expr: Expr.GreaterThanOrEqual<*>, jsonEncoder: JsonEncoder) { + jsonEncoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive(">="), + serializeExpr(expr.left, jsonEncoder), + serializeExpr(expr.right, jsonEncoder) + ) + ) + ) + } + + private fun serializeAll(value: Expr.All, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("all") + ) + value.conditions.map { condition -> + serializeExpr(condition, encoder) + }) + ) + } + + private fun serializeAnyOf(value: Expr.AnyOf, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("any") + ) + value.conditions.map { condition -> + serializeExpr(condition, encoder) + }) + ) + } + + private fun serializeNot(value: Expr.Not, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("!"), + serializeExpr(value.condition, encoder) + ) + ) + ) + } + + private fun serializeModulo(value: Expr.Modulo, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("%"), + serializeExpr(value.left, encoder), + serializeExpr(value.right, encoder) + ) + ) + ) + } + + private fun serializePower(value: Expr.Power, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("^"), + serializeExpr(value.left, encoder), + serializeExpr(value.right, encoder) + ) + ) + ) + } + + private fun serializeAbs(value: Expr.Abs, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("abs"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeAcos(value: Expr.Acos, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("acos"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeAsin(value: Expr.Asin, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("asin"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeAtan(value: Expr.Atan, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("atan"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeCeil(value: Expr.Ceil, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("ceil"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeCos(value: Expr.Cos, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("cos"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeFloor(value: Expr.Floor, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("floor"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeLn(value: Expr.Ln, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("ln"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeLog10(value: Expr.Log10, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("log10"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeMax(value: Expr.Max, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("max"), + serializeExpr(value.left, encoder), + serializeExpr(value.right, encoder) + ) + ) + ) + } + + private fun serializeMin(value: Expr.Min, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("min"), + serializeExpr(value.left, encoder), + serializeExpr(value.right, encoder) + ) + ) + ) + } + + private fun serializeRound(value: Expr.Round, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("round"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeSin(value: Expr.Sin, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("sin"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeSqrt(value: Expr.Sqrt, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("sqrt"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeTan(value: Expr.Tan, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("tan"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeConcat(value: Expr.Concat, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("concat"), + serializeExpr(value.left, encoder), + serializeExpr(value.right, encoder) + ) + ) + ) + } + + private fun serializeIndexOf(value: Expr.IndexOf<*>, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("index-of"), + serializeExpr(value.array, encoder), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeDowncase(value: Expr.Downcase, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("downcase"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeUpcase(value: Expr.Upcase, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("upcase"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeToBoolean(value: Expr.ToBoolean, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("to-boolean"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + private fun serializeToStringExpr(value: Expr.ToString<*>, encoder: JsonEncoder) { + encoder.encodeJsonElement( + JsonArray( + listOf( + JsonPrimitive("to-string"), + serializeExpr(value.value, encoder) + ) + ) + ) + } + + override fun serialize(encoder: Encoder, value: Expr) { + val jsonEncoder = + encoder as? JsonEncoder ?: throw SerializationException("This serializer can only be used with Json") + + // FIXME not working + when (value) { + is Expr.Get -> serializeGet(value, jsonEncoder) + is Expr.Match -> serializeMatch(value, jsonEncoder) + is Expr.ZoomStops -> serializeZoomStops(value, jsonEncoder) + is Expr.Constant -> serializeConstant(value, jsonEncoder) + is Expr.Raw -> serializeRaw(value, jsonEncoder) + is Expr.Has<*> -> serializeHas(value as Expr.Has, jsonEncoder) + is Expr.In<*> -> serializeIn(value as Expr.In, jsonEncoder) + is Expr.Case -> serializeCase(value, jsonEncoder) + is Expr.Coalesce -> serializeCoalesce(value, jsonEncoder) + is Expr.At -> serializeAt(value, jsonEncoder) + is Expr.Length<*> -> serializeLength(value as Expr.Length, jsonEncoder) + is Expr.Slice<*> -> serializeSlice(value as Expr.Slice, jsonEncoder) + is Expr.NotEquals<*, *> -> serializeNotEquals(value as Expr.NotEquals<*, *>, jsonEncoder) + is Expr.Equals<*, *> -> serializeEquals(value as Expr.Equals<*, *>, jsonEncoder) + is Expr.LessThan<*> -> serializeLessThan(value as Expr.LessThan<*>, jsonEncoder) + is Expr.LessThanOrEqual<*> -> serializeLessThanOrEqual(value as Expr.LessThanOrEqual<*>, jsonEncoder) + is Expr.GreaterThan<*> -> serializeGreaterThan(value as Expr.GreaterThan<*>, jsonEncoder) + is Expr.GreaterThanOrEqual<*> -> serializeGreaterThanOrEqual( + value as Expr.GreaterThanOrEqual<*>, + jsonEncoder + ) + + is Expr.Stops -> serializeStops(value, jsonEncoder) + is Expr.Interpolate -> serializeInterpolate(value, jsonEncoder) + is Expr.All -> serializeAll(value, jsonEncoder) + is Expr.AnyOf -> serializeAnyOf(value, jsonEncoder) + is Expr.Not -> serializeNot(value, jsonEncoder) + is Expr.Modulo -> serializeModulo(value, jsonEncoder) + is Expr.Power -> serializePower(value, jsonEncoder) + is Expr.Abs -> serializeAbs(value, jsonEncoder) + is Expr.Acos -> serializeAcos(value, jsonEncoder) + is Expr.Asin -> serializeAsin(value, jsonEncoder) + is Expr.Atan -> serializeAtan(value, jsonEncoder) + is Expr.Ceil -> serializeCeil(value, jsonEncoder) + is Expr.Cos -> serializeCos(value, jsonEncoder) + is Expr.Floor -> serializeFloor(value, jsonEncoder) + is Expr.Ln -> serializeLn(value, jsonEncoder) + is Expr.Log10 -> serializeLog10(value, jsonEncoder) + is Expr.Max -> serializeMax(value, jsonEncoder) + is Expr.Min -> serializeMin(value, jsonEncoder) + is Expr.Round -> serializeRound(value, jsonEncoder) + is Expr.Sin -> serializeSin(value, jsonEncoder) + is Expr.Sqrt -> serializeSqrt(value, jsonEncoder) + is Expr.Tan -> serializeTan(value, jsonEncoder) + is Expr.Concat<*> -> serializeConcat(value as Expr.Concat, jsonEncoder) + is Expr.IndexOf<*> -> serializeIndexOf(value as Expr.IndexOf, jsonEncoder) + is Expr.Downcase -> serializeDowncase(value, jsonEncoder) + is Expr.Upcase -> serializeUpcase(value, jsonEncoder) + is Expr.ToBoolean -> serializeToBoolean(value, jsonEncoder) + is Expr.ToString<*> -> serializeToStringExpr(value, jsonEncoder) + is Expr.Zoom -> jsonEncoder.encodeJsonElement(JsonArray(listOf(JsonPrimitive("zoom")))) + is Expr.Step<*> -> jsonEncoder.encodeJsonElement(JsonArray(listOf(JsonPrimitive("step")))) + is Expr.ToNumber -> jsonEncoder.encodeJsonElement(JsonArray(listOf(JsonPrimitive("to-number")))) + is Expr.Image -> jsonEncoder.encodeJsonElement(JsonArray(listOf(JsonPrimitive("to-number")))) + } + } + + private fun extractNestedExpressionChunk(from: JsonArray, startIndex: Int): Pair { + if (from[startIndex] is JsonPrimitive) { + return from[startIndex] to 1 + } + if (from[startIndex] is JsonArray) { + val arr = from[startIndex] as JsonArray + return arr to 1 + } + throw SerializationException("Unknown element type in extractNestedExpressionChunk: ${from[startIndex]}") + } + + private fun deserializeGet(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Get { + if (element.size != 2) throw SerializationException("Invalid get expression") + return Expr.Get(deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true)) + } + + private fun deserializeToNumber( + element: JsonArray, + jsonDecoder: JsonDecoder, + isNested: Boolean + ): Expr { + if (element.size != 2) throw SerializationException("Invalid to-number expression") + val inputExpr = deserializeExpr(element[1], jsonDecoder, isNested) + return Expr.ToNumber(inputExpr) + } + + private fun deserializeMatch(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Match { + if (element.size < 4) throw SerializationException("Invalid match expression") + val input = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + val branches = mutableListOf, Expr>>() + var i = 2 + while (i < element.size - 1) { + val valuesElement = element[i] + val resultElement = element[i + 1] + val values = when (valuesElement) { + is JsonArray -> valuesElement.map { it.jsonPrimitive.content } + is JsonPrimitive -> listOf(valuesElement.content) + else -> throw SerializationException("Invalid match value type") + } + val result = deserializeExpr( + element = resultElement, + jsonDecoder = jsonDecoder, + isNested = resultElement !is JsonPrimitive + ) as Expr + branches.add(values to result) + i += 2 + } + val elseElement = element.last() + val elseExpr = deserializeExpr( + element = elseElement, + jsonDecoder = jsonDecoder, + isNested = elseElement !is JsonPrimitive + ) as Expr + return Expr.Match(input, branches, elseExpr) + } + + private fun deserializeZoomStops( + element: JsonArray, + jsonDecoder: JsonDecoder, + isNested: Boolean + ): Expr.ZoomStops { + if (element.size != 2) throw SerializationException("Invalid zoom expression") + val stops: List>> = element[1].jsonArray.map { stop -> + if (stop !is JsonArray || stop.size != 2) throw SerializationException("Invalid stop format") + val zoom: Double = stop[0].jsonPrimitive.content.toDouble() + val value: Expr<@Contextual Any?> = deserializeExpr(element = stop[1], jsonDecoder = jsonDecoder, isNested = true) + Pair( + first = zoom, + second = value, + ) + } + return Expr.ZoomStops(stops) + } + + private fun deserializeHas(element: JsonArray): Expr.Has { + if (element.size != 2) throw SerializationException("Invalid has expression") + return Expr.Has(element[1].jsonPrimitive.content) + } + + private fun deserializeIn(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.In { + if (element.size != 3) throw SerializationException("Invalid in expression $element") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + val array = (element[2] as JsonArray).map { it.jsonPrimitive.content } + return Expr.In(value, array) + } + + private fun deserializeCase(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Case { + if (element.size < 4) throw SerializationException("Invalid case expression") + val conditions = mutableListOf, Expr>>() + for (i in 1 until element.size - 1 step 2) { + val condition = deserializeExpr(element = element[i], jsonDecoder = jsonDecoder, isNested = true) + val result = + deserializeExpr(element = element[i + 1], jsonDecoder = jsonDecoder, isNested = true) as Expr + conditions.add(condition to result) + } + val default = deserializeExpr(element = element.last(), jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Case(conditions, default) + } + + private fun deserializeCoalesce(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Coalesce { + if (element.size < 2) throw SerializationException("Invalid coalesce expression") + val values = element.drop(1).map { valueElement -> + deserializeExpr(element = valueElement, jsonDecoder = jsonDecoder, isNested = true) as Expr + } + return Expr.Coalesce(values) + } + + private fun deserializeInterpolate( + element: JsonArray, + jsonDecoder: JsonDecoder, + isNested: Boolean + ): Expr.Interpolate { + if (element.size < 4) throw SerializationException("Invalid interpolate expression") + + val interpolationType = when (val interpolationElement = element[1]) { + is JsonPrimitive -> when (interpolationElement.content) { + "linear" -> InterpolationType.Linear + "exponential" -> InterpolationType.Exponential(1.0) // TODO + "cubic" -> InterpolationType.Cubic + "step" -> InterpolationType.Step + else -> throw SerializationException("Invalid interpolation type") + } + + is JsonArray -> when (interpolationElement[0].jsonPrimitive.content) { + "linear" -> InterpolationType.Linear + "exponential" -> InterpolationType.Exponential(interpolationElement[1].jsonPrimitive.double) + "cubic" -> InterpolationType.Cubic + "step" -> InterpolationType.Step + else -> throw SerializationException("Invalid interpolation type") + } + + else -> throw SerializationException("Invalid interpolation type") + } + + val input = element[2].let { inputElement -> + val isZoom = (inputElement is JsonPrimitive && inputElement.content == "zoom") || + (inputElement is JsonArray && inputElement.size == 1 && inputElement[0].let { it is JsonPrimitive && it.content == "zoom" }) + if (isZoom) { + Expr.Zoom + } else if (inputElement is JsonArray && isExpr(inputElement)) { + deserializeExpr(element = inputElement, jsonDecoder = jsonDecoder, isNested = true) as Expr + } else { + deserializeConstant(inputElement, jsonDecoder, true) + } + } + + val stops = mutableListOf>>() + var i = 3 + while (i < element.size) { + val stop = element[i++].jsonPrimitive.double + val (valueElement, consumed) = extractNestedExpressionChunk(element, i) + val expr = when (valueElement) { + is JsonPrimitive -> deserializeConstant(valueElement, jsonDecoder, isNested = isNested) + is JsonArray -> { + if (isExpr(valueElement)) { + deserializeExpr(valueElement, jsonDecoder, isNested = true) as Expr + } else { + deserializeConstant(valueElement, jsonDecoder, isNested = isNested) + } + } + + else -> throw SerializationException("Invalid stop value type") + } + stops.add(stop to expr) + i += consumed + } + + return Expr.Interpolate( + interpolation = interpolationType, + input = input, + stops = stops + ) + } + + private fun deserializeAt(element: JsonArray, jsonDecoder: JsonDecoder): Expr.At { + if (element.size != 3) throw SerializationException("Invalid at expression") + val index = element[1].jsonPrimitive.content.toInt() + val array = jsonDecoder.json.decodeFromJsonElement(valueSerializer, element[2]) as Expr> + return Expr.At(index, array) + } + + private fun deserializeLength(element: JsonArray, jsonDecoder: JsonDecoder): Expr.Length { + if (element.size != 2) throw SerializationException("Invalid length expression") + val array = jsonDecoder.json.decodeFromJsonElement(valueSerializer, element[1]) as Expr> + return Expr.Length(array) + } + + private fun deserializeSlice(element: JsonArray, jsonDecoder: JsonDecoder): Expr.Slice { + if (element.size < 3 || element.size > 4) throw SerializationException("Invalid slice expression") + val array = jsonDecoder.json.decodeFromJsonElement(valueSerializer, element[1]) as Expr> + val start = element[2].jsonPrimitive.content.toInt() + val end = if (element.size == 4) element[3].jsonPrimitive.content.toInt() else null + return Expr.Slice(array, start, end) + } + + private fun deserializeNotEquals( + element: JsonArray, + jsonDecoder: JsonDecoder, + isNested: Boolean + ): Expr.NotEquals<*, *> { + if (element.size != 3) throw SerializationException("Invalid not equals expression") + val left = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) + val right = deserializeExpr(element = element[2], jsonDecoder = jsonDecoder, isNested = true) + return Expr.NotEquals(left, right) + } + + private fun deserializeEquals(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Equals<*, *> { + if (element.size != 3) throw SerializationException("Invalid equals expression") + val left = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) + val right = deserializeExpr(element = element[2], jsonDecoder = jsonDecoder, isNested = true) + return Expr.Equals(left, right) + } + + private fun deserializeLessThan(input: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr<@Contextual Any?> { + if (input.size != 3) throw SerializationException("Invalid 'less than' expression format") + return Expr.LessThan>( + deserializeExpr(element = input[1], jsonDecoder = jsonDecoder, isNested = true) as Expr>, + deserializeExpr(element = input[2], jsonDecoder = jsonDecoder, isNested = true) as Expr> + ) + } + + private fun deserializeLessThanOrEqual(input: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr<@Contextual Any?> { + if (input.size != 3) throw SerializationException("Invalid 'less than or equal' expression format") + return Expr.LessThanOrEqual>( + deserializeExpr(element = input[1], jsonDecoder = jsonDecoder, isNested = true) as Expr>, + deserializeExpr(element = input[2], jsonDecoder = jsonDecoder, isNested = true) as Expr> + ) + } + + private fun deserializeGreaterThan(input: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr<@Contextual Any?> { + if (input.size != 3) throw SerializationException("Invalid 'greater than' expression format") + return Expr.GreaterThan>( + deserializeExpr(element = input[1], jsonDecoder = jsonDecoder, isNested = true) as Expr>, + deserializeExpr(element = input[2], jsonDecoder = jsonDecoder, isNested = true) as Expr> + ) + } + + private fun deserializeGreaterThanOrEqual(input: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr<@Contextual Any?> { + if (input.size != 3) throw SerializationException("Invalid 'greater than or equal' expression format") + return Expr.GreaterThanOrEqual>( + deserializeExpr(element = input[1], jsonDecoder = jsonDecoder, isNested = true) as Expr>, + deserializeExpr(element = input[2], jsonDecoder = jsonDecoder, isNested = true) as Expr> + ) + } + + private fun deserializeAll(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.All { + if (element.size < 2) throw SerializationException("Invalid all expression") + val conditions = element.drop(1).map { conditionElement -> + deserializeExpr(element = conditionElement, jsonDecoder = jsonDecoder, isNested = true) as Expr + } + return Expr.All(conditions) + } + + private fun deserializeAnyOf(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.AnyOf { + if (element.size < 2) throw SerializationException("Invalid any expression") + val conditions = element.drop(1).map { conditionElement -> + deserializeExpr(element = conditionElement, jsonDecoder = jsonDecoder, isNested = true) as Expr + } + return Expr.AnyOf(conditions) + } + + private fun deserializeNot(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Not { + if (element.size != 2) throw SerializationException("Invalid not expression") + val condition = + deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Not(condition) + } + + private fun deserializeModulo(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Modulo { + if (element.size != 3) throw SerializationException("Invalid modulo expression") + val left = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + val right = deserializeExpr(element = element[2], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Modulo(left, right) + } + + private fun deserializePower(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Power { + if (element.size != 3) throw SerializationException("Invalid power expression") + val left = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + val right = deserializeExpr(element = element[2], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Power(left, right) + } + + private fun deserializeAbs(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Abs { + if (element.size != 2) throw SerializationException("Invalid abs expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Abs(value) + } + + private fun deserializeAcos(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Acos { + if (element.size != 2) throw SerializationException("Invalid acos expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Acos(value) + } + + private fun deserializeAsin(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Asin { + if (element.size != 2) throw SerializationException("Invalid asin expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Asin(value) + } + + private fun deserializeAtan(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Atan { + if (element.size != 2) throw SerializationException("Invalid atan expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Atan(value) + } + + private fun deserializeCeil(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Ceil { + if (element.size != 2) throw SerializationException("Invalid ceil expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Ceil(value) + } + + private fun deserializeCos(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Cos { + if (element.size != 2) throw SerializationException("Invalid cos expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Cos(value) + } + + private fun deserializeFloor(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Floor { + if (element.size != 2) throw SerializationException("Invalid floor expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Floor(value) + } + + private fun deserializeLn(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Ln { + if (element.size != 2) throw SerializationException("Invalid ln expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Ln(value) + } + + private fun deserializeLog10(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Log10 { + if (element.size != 2) throw SerializationException("Invalid log10 expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Log10(value) + } + + private fun deserializeMax(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Max { + if (element.size != 3) throw SerializationException("Invalid max expression") + val left = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + val right = deserializeExpr(element = element[2], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Max(left, right) + } + + private fun deserializeMin(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Min { + if (element.size != 3) throw SerializationException("Invalid min expression") + val left = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + val right = deserializeExpr(element = element[2], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Min(left, right) + } + + private fun deserializeRound(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Round { + if (element.size != 2) throw SerializationException("Invalid round expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Round(value) + } + + private fun deserializeSin(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Sin { + if (element.size != 2) throw SerializationException("Invalid sin expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Sin(value) + } + + private fun deserializeSqrt(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Sqrt { + if (element.size != 2) throw SerializationException("Invalid sqrt expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Sqrt(value) + } + + private fun deserializeTan(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Tan { + if (element.size != 2) throw SerializationException("Invalid tan expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Tan(value) + } + + private fun deserializeConcat(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Concat { + if (element.size != 3) throw SerializationException("Invalid concat expression") + val left = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr> + val right = deserializeExpr(element = element[2], jsonDecoder = jsonDecoder, isNested = true) as Expr> + return Expr.Concat(left, right) + } + + private fun deserializeIndexOf(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.IndexOf { + if (element.size != 3) throw SerializationException("Invalid index-of expression") + val array = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr> + val value = deserializeExpr(element = element[2], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.IndexOf(array, value) + } + + private fun deserializeDowncase(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Downcase { + if (element.size != 2) throw SerializationException("Invalid downcase expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Downcase(value) + } + + private fun deserializeUpcase(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Upcase { + if (element.size != 2) throw SerializationException("Invalid upcase expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.Upcase(value) + } + + private fun deserializeToBoolean(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.ToBoolean { + if (element.size != 2) throw SerializationException("Invalid to-boolean expression") + val value = deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + return Expr.ToBoolean(value) + } + + private fun deserializeToString(array: JsonArray, decoder: JsonDecoder, isNested: Boolean): Expr.ToString<*> { + require(array.size == 2) { "to-string expression must have exactly one argument" } + val value = deserializeExpr(element = array[1], jsonDecoder = decoder, isNested = true) as Expr + return Expr.ToString(value) + } + + private fun deserializeLiteral(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr { + if (element.size != 2) throw SerializationException("Invalid literal expression") + return deserializeExpr(element = element[1], jsonDecoder = jsonDecoder, isNested = true) as Expr + } + + private fun deserializeConstant( + element: JsonElement, + jsonDecoder: JsonDecoder, + isNested: Boolean = false + ): Expr.Constant { + return when (element) { + is JsonPrimitive -> { + if (isNested) { + if (element.isString) { + return Expr.Constant(element.content) as Expr.Constant + } + val booleanOrNull = element.booleanOrNull + if (booleanOrNull != null) { + return Expr.Constant(booleanOrNull) as Expr.Constant + } + + val doubleOrNull = element.doubleOrNull + if (doubleOrNull != null) { + return Expr.Constant(doubleOrNull) as Expr.Constant + } + + error("cant deserialize an element $element") + } else { + return Expr.Constant(jsonDecoder.json.decodeFromJsonElement(valueSerializer, element)) + } + } + + is JsonArray -> { + Expr.Constant(jsonDecoder.json.decodeFromJsonElement(valueSerializer, element)) + } + + else -> error("Unsupported constant type") + } as Expr.Constant + } + + private fun deserializeImage( + element: JsonArray, + jsonDecoder: JsonDecoder, + isNested: Boolean + ): Expr { + if (element.size != 2) throw SerializationException("Invalid image expression") + val nested = element[1] + val inputExpr = deserializeExpr(nested, jsonDecoder, isNested = nested is JsonArray && isExpr(nested)) + return Expr.Image(inputExpr) + } + + private fun deserializeStep(element: JsonArray, jsonDecoder: JsonDecoder, isNested: Boolean): Expr.Step { + if (element.size < 3) throw SerializationException("Invalid step expression") + + val input = when (val inputElement = element[1]) { + is JsonArray -> { + when (inputElement[0].jsonPrimitive.content) { + "zoom" -> Expr.Zoom as Expr + else -> deserializeExpr( + element = inputElement, + jsonDecoder = jsonDecoder, + isNested = true + ) as Expr + } + } + + is JsonPrimitive -> deserializeConstant(inputElement, jsonDecoder, true) + else -> throw SerializationException("Invalid input expression") + } + + val default = deserializeExpr(element = element[2], jsonDecoder = jsonDecoder, isNested = true) as Expr + + val stops = mutableListOf>>() + var i = 3 + while (i < element.size - 1) { + val stop = element[i++].jsonPrimitive.double + val value = deserializeExpr(element = element[i++], jsonDecoder = jsonDecoder, isNested = true) as Expr + stops.add(stop to value) + } + + return Expr.Step(input as Expr, default, stops) + } + + fun deserializeExpr(element: JsonElement, jsonDecoder: JsonDecoder, isNested: Boolean): Expr<@Contextual Any?> { + return when (element) { + is JsonPrimitive -> deserializeConstant(element = element, jsonDecoder = jsonDecoder, isNested = isNested) + is JsonObject -> { + if ("stops" in element || "type" in element || "property" in element) { + deserializeExpr( + element = normalizeLegacyExpression(element), + jsonDecoder = jsonDecoder, + isNested = true + ) + } else { + error("Unknown object expression: $element") + } + } + + is JsonArray -> { + if (!isExpr(element)) return deserializeConstant( + element = element, + jsonDecoder = jsonDecoder, + isNested = isNested + ) + val operator = element.firstOrNull()?.jsonPrimitive?.content + + when (operator) { + "get" -> deserializeGet(element, jsonDecoder, isNested) + "match" -> deserializeMatch(element, jsonDecoder, isNested) + "zoom" -> deserializeZoomStops(element, jsonDecoder, isNested) + "interpolate" -> deserializeInterpolate(element, jsonDecoder, isNested) + "has" -> deserializeHas(element) + "in" -> deserializeIn(element, jsonDecoder, isNested) + "case" -> deserializeCase(element, jsonDecoder, isNested) + "coalesce" -> deserializeCoalesce(element, jsonDecoder, isNested) + "at" -> deserializeAt(element, jsonDecoder) + "length" -> deserializeLength(element, jsonDecoder) + "slice" -> deserializeSlice(element, jsonDecoder) + "!=" -> deserializeNotEquals(element, jsonDecoder, isNested) + "==" -> deserializeEquals(element, jsonDecoder, isNested) + "<" -> deserializeLessThan(element, jsonDecoder, isNested) + "<=" -> deserializeLessThanOrEqual(element, jsonDecoder, isNested) + ">" -> deserializeGreaterThan(element, jsonDecoder, isNested) + ">=" -> deserializeGreaterThanOrEqual(element, jsonDecoder, isNested) + "all" -> deserializeAll(element, jsonDecoder, isNested) + "any" -> deserializeAnyOf(element, jsonDecoder, isNested) + "!" -> deserializeNot(element, jsonDecoder, isNested) + "%" -> deserializeModulo(element, jsonDecoder, isNested) + "^" -> deserializePower(element, jsonDecoder, isNested) + "abs" -> deserializeAbs(element, jsonDecoder, isNested) + "acos" -> deserializeAcos(element, jsonDecoder, isNested) + "asin" -> deserializeAsin(element, jsonDecoder, isNested) + "atan" -> deserializeAtan(element, jsonDecoder, isNested) + "ceil" -> deserializeCeil(element, jsonDecoder, isNested) + "cos" -> deserializeCos(element, jsonDecoder, isNested) + "floor" -> deserializeFloor(element, jsonDecoder, isNested) + "ln" -> deserializeLn(element, jsonDecoder, isNested) + "log10" -> deserializeLog10(element, jsonDecoder, isNested) + "max" -> deserializeMax(element, jsonDecoder, isNested) + "min" -> deserializeMin(element, jsonDecoder, isNested) + "round" -> deserializeRound(element, jsonDecoder, isNested) + "sin" -> deserializeSin(element, jsonDecoder, isNested) + "sqrt" -> deserializeSqrt(element, jsonDecoder, isNested) + "tan" -> deserializeTan(element, jsonDecoder, isNested) + "stops" -> deserializeZoomStops(element, jsonDecoder, isNested) + "concat" -> deserializeConcat(element, jsonDecoder, isNested) + "index-of" -> deserializeIndexOf(element, jsonDecoder, isNested) + "downcase" -> deserializeDowncase(element, jsonDecoder, isNested) + "upcase" -> deserializeUpcase(element, jsonDecoder, isNested) + "to-boolean" -> deserializeToBoolean(element, jsonDecoder, isNested) + "to-string" -> deserializeToString(element, jsonDecoder, isNested) + "literal" -> deserializeLiteral(element, jsonDecoder, isNested) + "step" -> deserializeStep(element, jsonDecoder, isNested) + "to-number" -> deserializeToNumber(element, jsonDecoder, isNested) + "image" -> deserializeImage(element, jsonDecoder, isNested) + else -> error("unsupported expression $operator in $element") + } + } + } + } + + override fun deserialize(decoder: Decoder): Expr { + val jsonDecoder = + decoder as? JsonDecoder ?: throw SerializationException("This serializer can only be used with Json") + val element = jsonDecoder.decodeJsonElement() + + @Suppress("UNCHECKED_CAST") + return deserializeExpr(element = element, jsonDecoder = jsonDecoder, isNested = false) as Expr + } +} \ 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..5de3c288 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/ExpressionOrValueSerializer.kt @@ -0,0 +1,83 @@ +package ovh.plrapps.mapcompose.vector.spec.style.serializers + +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.* +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.normalizeLegacyExpression + +@OptIn(ExperimentalSerializationApi::class) +@Serializer(forClass = ExpressionOrValue::class) +class ExpressionOrValueSerializer( + private val valueSerializer: KSerializer +) : KSerializer> { + + private val exprSerializer = ExprSerializerFactory.create(valueSerializer) + + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ExpressionOrValue") + + @OptIn(ExperimentalSerializationApi::class) + 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 + ) + ) + } + } + } + + is ExpressionOrValue.Expression -> { + jsonEncoder.encodeJsonElement(jsonEncoder.json.encodeToJsonElement(exprSerializer, value.expr)) + } + } + } + + 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 isExpression = ExpressionOrValue.isExpression(element) + return if (isExpression) { + ExpressionOrValue.Expression( + jsonDecoder.json.decodeFromJsonElement( + deserializer = exprSerializer, + element = if (element is JsonObject) normalizeLegacyExpression(element) else element + ), + source = element.toString() + ) + } else { + when (valueSerializer) { + is ColorSerializer -> { + val color = ColorParser.parseColorStringOrNull(element.jsonPrimitive.content) + ?: throw SerializationException("Invalid color format ${element.jsonPrimitive.content}") + ExpressionOrValue.Value(color as T, source = element.toString()) + } + + else -> { + ExpressionOrValue.Value( + jsonDecoder.json.decodeFromJsonElement(valueSerializer, element), + source = element.toString() + ) + } + } + } + } +} \ No newline at end of file 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..b4b93667 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/symbol/SymbolLayout.kt @@ -0,0 +1,154 @@ +package ovh.plrapps.mapcompose.vector.spec.style.symbol + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.KSerializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import ovh.plrapps.mapcompose.vector.spec.style.LayoutInterface +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue + +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) +} + +@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..8d98c0a8 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/symbol/TextAnchor.kt @@ -0,0 +1,22 @@ +package ovh.plrapps.mapcompose.vector.spec.style.symbol + +import kotlinx.serialization.Serializable + +@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 + } + } +} \ No newline at end of file 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..8c985dfe --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/ColorParser.kt @@ -0,0 +1,278 @@ +package ovh.plrapps.mapcompose.vector.spec.style.utils + +import androidx.compose.ui.graphics.Color +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) + ) + + fun parseColorString(color: String): Color { + 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/KnownExpressions.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/KnownExpressions.kt new file mode 100644 index 00000000..1c0f4aa6 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/KnownExpressions.kt @@ -0,0 +1,44 @@ +package ovh.plrapps.mapcompose.vector.spec.style.utils + + +internal val knownExpressions = setOf( + // 🧮 Arithmetic + "+", "-", "*", "/", "%", "^", + + // 🧠 Type & Conversion + "typeof", "to-number", "to-string", "to-boolean", "to-color", "number-format", + + // 🎨 Color + "rgb", "rgba", "to-rgba", + + // 🔢 Math + "abs", "acos", "asin", "atan", "ceil", "cos", "e", "floor", "ln", "ln2", + "log10", "log2", "max", "min", "pi", "round", "sin", "sqrt", "tan", + + // 🔗 Logical + "!", "==", "!=", ">", ">=", "<", "<=", "all", "any", + + // 🔀 Control Flow + "case", "match", "coalesce", "step", "interpolate", "interpolate-hcl", "interpolate-lab", + + // 🧰 Data Manipulation + "get", "has", "in", "at", "index-of", "length", "slice", + + // 🧾 Feature + "feature-state", "geometry-type", "id", "properties", + + // 📷 Camera + "zoom", "line-progress", + + // 📦 Constants + "literal", + + // 🔡 String + "concat", "downcase", "upcase", "is-supported-script", "resolved-locale", + + // 🧠 Structures + "array", "boolean", "collator", "format", "image", "number", "object", "string", + + // 🧪 Variables + "let", "var" +) \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/isExpr.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/isExpr.kt new file mode 100644 index 00000000..6634908c --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/isExpr.kt @@ -0,0 +1,9 @@ +package ovh.plrapps.mapcompose.vector.spec.style.utils + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive + +internal fun isExpr(jsonElement: JsonArray): Boolean { + val first = jsonElement.firstOrNull() + return first is JsonPrimitive && first.isString && first.content in knownExpressions +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/normalizeLegacyExpression.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/normalizeLegacyExpression.kt new file mode 100644 index 00000000..3e0e2d15 --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/normalizeLegacyExpression.kt @@ -0,0 +1,124 @@ +package ovh.plrapps.mapcompose.vector.spec.style.utils + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.* + +@OptIn(ExperimentalSerializationApi::class) +fun normalizeLegacyExpression(input: JsonObject): JsonArray { + + val type = input["type"]?.jsonPrimitive?.contentOrNull + val property = input["property"]?.jsonPrimitive?.contentOrNull + val stops = input["stops"] as? JsonArray + val base = input["base"]?.jsonPrimitive?.floatOrNull + + return when { + base != null && stops != null && property == null -> buildJsonArray { + add("interpolate") + add(buildJsonArray { + add("exponential") + add(base) + }) + add("zoom") + stops.forEach { stop -> + if (stop is JsonArray && stop.size == 2) { + add(stop[0]) + add(stop[1]) + } + } + } + // identity → ["get", property] + type == "identity" && property != null -> buildJsonArray { + add("get") + add(property) + } + + // exponential → ["interpolate", ["exponential", base], ["get", property], ...stops] + type == "exponential" && property != null && stops != null -> buildJsonArray { + add("interpolate") + add(buildJsonArray { + add("exponential") + add(JsonPrimitive(base ?: 1f)) + }) + add(buildJsonArray { + add("get") + add(property) + }) + stops.forEach { stop -> + if (stop is JsonArray && stop.size == 2) { + add(stop[0]) + add(stop[1]) + } + } + } + + type == "interval" && property != null && stops != null -> buildJsonArray { + add("step") + add(buildJsonArray { + add("get") + add(property) + }) + + val default = input["default"] ?: JsonPrimitive(0) + add(default) + + stops.forEach { stop -> + if (stop is JsonArray && stop.size == 2) { + add(stop[0]) + add(stop[1]) + } + } + } + + // categorical → ["match", ["get", property], ...] + type == "categorical" && property != null && stops != null -> buildJsonArray { + add("match") + add(buildJsonArray { + add("get") + add(property) + }) + stops.forEachIndexed { index, stop -> + if (stop is JsonArray && stop.size == 2) { + add(stop[0]) + add(stop[1]) + } + } + add(JsonPrimitive(null)) // default fallback + } + + // stops-only shorthand → assume interpolate linear + stops != null && property != null -> buildJsonArray { + add("interpolate") + add(buildJsonArray { add("linear") }) + add(buildJsonArray { + add("get") + add(property) + }) + stops.forEach { stop -> + if (stop is JsonArray && stop.size == 2) { + add(stop[0]) + add(stop[1]) + } + } + } + // stops-only without property → assume zoom-based interpolation + stops != null && property == null -> buildJsonArray { + add("interpolate") + add(buildJsonArray { add("linear") }) + add(JsonPrimitive("zoom")) + stops.forEach { stop -> + if (stop is JsonArray && stop.size == 2) { + add(stop[0]) + add(stop[1]) + } + } + } + + // only property → assume ["get", property] + property != null -> buildJsonArray { + add("get") + add(property) + } + + else -> error("unable to normalize legacy expression $input") // fallback + } +} diff --git a/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/normalizePotentialLiteralArray.kt b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/normalizePotentialLiteralArray.kt new file mode 100644 index 00000000..7c04f4cf --- /dev/null +++ b/library/src/commonMain/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/normalizePotentialLiteralArray.kt @@ -0,0 +1,17 @@ +package ovh.plrapps.mapcompose.vector.spec.style.utils + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray + +// Not sure to use. for discuss +fun normalizePotentialLiteralArray(element: JsonArray): JsonArray { + return if (isExpr(element)) { + element // expression + } else { + buildJsonArray { + add("literal") + add(element) + } + } +} \ No newline at end of file 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/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/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/kotlin/ovh/plrapps/mapcompose/core/TileCollectorTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/core/TileCollectorTest.kt index 9c2c29a5..d6dba52b 100644 --- a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/core/TileCollectorTest.kt +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/core/TileCollectorTest.kt @@ -41,7 +41,7 @@ class TileCollectorTest { val visibleTileLocationsChannel = Channel(capacity = Channel.RENDEZVOUS) val tilesOutput = Channel(capacity = Channel.RENDEZVOUS) - val tileStreamProvider = TileStreamProvider { _, _, _ -> + val tileStreamProvider = TileStreamProvider { _, _, _, _ -> makeTile() } 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..4423d43d --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/renderer/collision/CollisionDetectorTest.kt @@ -0,0 +1,135 @@ +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 { + @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 testCollisionWithPriority() { + val detector = CollisionDetector() + val p1 = LabelPlacement( + "A", + ObbPoint(0f, 0f), + 0f, + Rect(0f, 0f, 10f, 10f), + obb = OBB( + center = ObbPoint((0f + 10f) / 2, (0f + 10f) / 2), + size = ObbSize(10f - 0f, 10f - 0f), + rotation = 0f + ), + priority = 1, + allowOverlap = false, + ignorePlacement = false, + ) + // TODO WIP + val p2 = LabelPlacement( + "B", + ObbPoint(5f, 5f), + 0f, + Rect(5f, 5f, 15f, 15f), + obb = OBB( + center = ObbPoint((5f + 15f) / 2, (5f + 15f) / 2), + size = ObbSize(15f - 5f, 15f - 5f), + rotation = 0f + ), + priority = 2, + allowOverlap = false, + ignorePlacement = false + ) + + assertTrue(detector.tryPlaceLabel(p1)) + } + + @Test + fun testAllowOverlap() { + val detector = CollisionDetector() + val p1 = LabelPlacement( + "A", + ObbPoint(0f, 0f), + 0f, + Rect(0f, 0f, 10f, 10f), + obb = OBB( + center = ObbPoint((0f + 10f) / 2, (0f + 10f) / 2), + size = ObbSize(10f - 0f, 10f - 0f), + rotation = 0f + ), + priority = 1, + allowOverlap = true, + ignorePlacement = false + ) + val p2 = LabelPlacement( + "B", + ObbPoint(5f, 5f), + 0f, + Rect(5f, 5f, 15f, 15f), + obb = OBB( + center = ObbPoint((5f + 15f) / 2, (5f + 15f) / 2), + size = ObbSize(15f - 5f, 15f - 5f), + rotation = 0f + ), + priority = 1, + allowOverlap = false, + ignorePlacement = false + ) + + assertTrue(detector.tryPlaceLabel(p1)) + assertTrue(detector.tryPlaceLabel(p2)) + } + + @Test + fun testIgnorePlacement() { + val detector = CollisionDetector() + val p1 = LabelPlacement( + "A", + ObbPoint(0f, 0f), + 0f, + Rect(0f, 0f, 10f, 10f), + obb = OBB( + center = ObbPoint((0f + 10f) / 2, (0f + 10f) / 2), + size = ObbSize(10f - 0f, 10f - 0f), + rotation = 0f + ), + priority = 1, + allowOverlap = false, + ignorePlacement = true + ) + val p2 = LabelPlacement( + "B", + ObbPoint(5f, 5f), + 0f, + Rect(5f, 5f, 15f, 15f), + obb = OBB( + center = ObbPoint((5f + 15f) / 2, (5f + 15f) / 2), + size = ObbSize(15f - 5f, 15f - 5f), + rotation = 0f + ), + priority = 1, + allowOverlap = false, + ignorePlacement = false + ) + + assertTrue(detector.tryPlaceLabel(p1)) + assertTrue(detector.tryPlaceLabel(p2)) + } +} \ No newline at end of file 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..0aa278ad --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/ColorParserTest.kt @@ -0,0 +1,104 @@ +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)")) + assertNull(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)")) + assertNull(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%)")) + assertNull(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%)")) + assertNull(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/FilterTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/FilterTest.kt new file mode 100644 index 00000000..9450b86a --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/FilterTest.kt @@ -0,0 +1,469 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +import kotlinx.serialization.json.* +import ovh.plrapps.mapcompose.vector.spec.style.Filter.Companion.TYPE_PROPERTY +import kotlin.test.* + +class FilterTest { + @Test + fun testEquals() { + val filter = Filter.Eq( + FilterOperand.Get("name"), + FilterOperand.Literal("test") + ) + assertTrue(filter.process(mapOf("name" to "test"), 0.0)) + assertFalse(filter.process(mapOf("name" to "other"), 0.0)) + } + + @Test + fun testNotEquals() { + val filter = Filter.Neq( + FilterOperand.Get("name"), + FilterOperand.Literal("test") + ) + assertFalse(filter.process(mapOf("name" to "test"), 0.0)) + assertTrue(filter.process(mapOf("name" to "other"), 0.0)) + } + + @Test + fun testGreaterThan() { + val filter = Filter.Gt( + FilterOperand.Get("count"), + FilterOperand.Literal(5) + ) + assertTrue(filter.process(mapOf("count" to 10), 0.0)) + assertFalse(filter.process(mapOf("count" to 3), 0.0)) + } + + @Test + fun testLessThan() { + val filter = Filter.Lt( + FilterOperand.Get("count"), + FilterOperand.Literal(5) + ) + assertTrue(filter.process(mapOf("count" to 3), 0.0)) + assertFalse(filter.process(mapOf("count" to 10), 0.0)) + } + + @Test + fun testIn() { + val filter = Filter.InList( + FilterOperand.Get("name"), + listOf( + FilterOperand.Literal("test1"), + FilterOperand.Literal("test2") + ) + ) + assertTrue(filter.process(mapOf("name" to "test1"), 0.0)) + assertTrue(filter.process(mapOf("name" to "test2"), 0.0)) + assertFalse(filter.process(mapOf("name" to "other"), 0.0)) + } + + @Test + fun testNotIn() { + val filter = Filter.NotInList( + FilterOperand.Get("name"), + listOf( + FilterOperand.Literal("test1"), + FilterOperand.Literal("test2") + ) + ) + assertFalse(filter.process(mapOf("name" to "test1"), 0.0)) + assertFalse(filter.process(mapOf("name" to "test2"), 0.0)) + assertTrue(filter.process(mapOf("name" to "other"), 0.0)) + } + + @Test + fun testHas() { + val filter = Filter.Has("name") + assertTrue(filter.process(mapOf("name" to "test"), 0.0)) + assertFalse(filter.process(mapOf("other" to "test"), 0.0)) + } + + @Test + fun testNotHas() { + val filter = Filter.NotHas("name") + assertFalse(filter.process(mapOf("name" to "test"), 0.0)) + assertTrue(filter.process(mapOf("other" to "test"), 0.0)) + } + + @Test + fun testAll() { + val filter = Filter.All( + listOf( + Filter.Eq( + FilterOperand.Get("name"), + FilterOperand.Literal("test") + ), + Filter.InList( + FilterOperand.Get("type"), + listOf(FilterOperand.Literal("point")) + ) + ) + ) + assertTrue(filter.process(mapOf("name" to "test", "type" to "point"), 0.0)) + assertFalse(filter.process(mapOf("name" to "other", "type" to "point"), 0.0)) + assertFalse(filter.process(mapOf("name" to "test", "type" to "line"), 0.0)) + } + + @Test + fun testAny() { + val filter = Filter.AnyOf( + listOf( + Filter.Eq( + FilterOperand.Get("name"), + FilterOperand.Literal("test1") + ), + Filter.Eq( + FilterOperand.Get("name"), + FilterOperand.Literal("test2") + ) + ) + ) + assertTrue(filter.process(mapOf("name" to "test1"), 0.0)) + assertTrue(filter.process(mapOf("name" to "test2"), 0.0)) + assertFalse(filter.process(mapOf("name" to "other"), 0.0)) + } + + @Test + fun testNone() { + val filter = Filter.None( + listOf( + Filter.Eq( + FilterOperand.Get("name"), + FilterOperand.Literal("test1") + ), + Filter.Eq( + FilterOperand.Get("name"), + FilterOperand.Literal("test2") + ) + ) + ) + assertFalse(filter.process(mapOf("name" to "test1"), 0.0)) + assertFalse(filter.process(mapOf("name" to "test2"), 0.0)) + assertTrue(filter.process(mapOf("name" to "other"), 0.0)) + } + + @Test + fun testNestedFilters() { + val filter = Filter.All( + listOf( + Filter.AnyOf( + listOf( + Filter.Eq( + FilterOperand.Get("name"), + FilterOperand.Literal("test1") + ), + Filter.Eq( + FilterOperand.Get("name"), + FilterOperand.Literal("test2") + ) + ) + ), + Filter.NotInList( + FilterOperand.Get("type"), + listOf(FilterOperand.Literal("line")) + ) + ) + ) + assertTrue(filter.process(mapOf("name" to "test1", "type" to "point"), 0.0)) + assertTrue(filter.process(mapOf("name" to "test2", "type" to "point"), 0.0)) + assertFalse(filter.process(mapOf("name" to "other", "type" to "point"), 0.0)) + assertFalse(filter.process(mapOf("name" to "test1", "type" to "line"), 0.0)) + } + + @Test + fun testDeeplyNestedFilters() { + val filter = Filter.All( + listOf( + Filter.AnyOf( + listOf( + Filter.Eq( + FilterOperand.Get("name"), + FilterOperand.Literal("test1") + ), + Filter.Eq( + FilterOperand.Get("name"), + FilterOperand.Literal("test2") + ) + ) + ), + Filter.All( + listOf( + Filter.NotInList( + FilterOperand.Get("type"), + listOf(FilterOperand.Literal("line")) + ), + Filter.AnyOf( + listOf( + Filter.Eq( + FilterOperand.Get("color"), + FilterOperand.Literal("red") + ), + Filter.Eq( + FilterOperand.Get("color"), + FilterOperand.Literal("blue") + ) + ) + ) + ) + ) + ) + ) + assertTrue(filter.process(mapOf("name" to "test1", "type" to "point", "color" to "red"), 0.0)) + assertTrue(filter.process(mapOf("name" to "test2", "type" to "point", "color" to "blue"), 0.0)) + assertFalse(filter.process(mapOf("name" to "other", "type" to "point", "color" to "red"), 0.0)) + assertFalse(filter.process(mapOf("name" to "test1", "type" to "line", "color" to "red"), 0.0)) + assertFalse(filter.process(mapOf("name" to "test1", "type" to "point", "color" to "green"), 0.0)) + } + + @Test + fun testComplexNestedFilters() { + val filter = Filter.All( + listOf( + Filter.AnyOf( + listOf( + Filter.Eq( + FilterOperand.Get("name"), + FilterOperand.Literal("test1") + ), + Filter.Eq( + FilterOperand.Get("name"), + FilterOperand.Literal("test2") + ) + ) + ), + Filter.All( + listOf( + Filter.NotInList( + FilterOperand.Get("type"), + listOf(FilterOperand.Literal("line")) + ), + Filter.AnyOf( + listOf( + Filter.Eq( + FilterOperand.Get("color"), + FilterOperand.Literal("red") + ), + Filter.Eq( + FilterOperand.Get("color"), + FilterOperand.Literal("blue") + ) + ) + ) + ) + ), + Filter.AnyOf( + listOf( + Filter.Eq( + FilterOperand.Get("size"), + FilterOperand.Literal("small") + ), + Filter.Eq( + FilterOperand.Get("size"), + FilterOperand.Literal("medium") + ) + ) + ) + ) + ) + assertTrue(filter.process(mapOf("name" to "test1", "type" to "point", "color" to "red", "size" to "small"), 0.0)) + assertTrue(filter.process(mapOf("name" to "test2", "type" to "point", "color" to "blue", "size" to "medium"), 0.0)) + assertFalse(filter.process(mapOf("name" to "other", "type" to "point", "color" to "red", "size" to "small"), 0.0)) + assertFalse(filter.process(mapOf("name" to "test1", "type" to "line", "color" to "red", "size" to "small"), 0.0)) + assertFalse(filter.process(mapOf("name" to "test1", "type" to "point", "color" to "green", "size" to "small"), 0.0)) + assertFalse(filter.process(mapOf("name" to "test1", "type" to "point", "color" to "red", "size" to "large"), 0.0)) + } + + @Test + fun testType() { + val filter = Filter.Type("point") + assertTrue(filter.process(mapOf("type" to "point"), 0.0)) + assertFalse(filter.process(mapOf("type" to "line"), 0.0)) + } + + @Test + fun testTypeWithOtherFilters() { + val filter = Filter.All( + listOf( + Filter.Type("point"), + Filter.Eq( + FilterOperand.Get("type"), + FilterOperand.Literal("point") + ) + ) + ) + assertTrue(filter.process(mapOf("type" to "point"), 0.0)) + assertFalse(filter.process(mapOf("type" to "line"), 0.0)) + } + + @Test + fun testTypeWithNestedFilters() { + val filter = Filter.All( + listOf( + Filter.AnyOf( + listOf( + Filter.Type("point"), + Filter.Type("line") + ) + ) + ) + ) + assertTrue(filter.process(mapOf("type" to "point"), 0.0)) + assertTrue(filter.process(mapOf("type" to "line"), 0.0)) + assertFalse(filter.process(mapOf("type" to "polygon"), 0.0)) + } + + @Test + fun testParseFilterJson() { + val json = """ + [ + "all", + ["in", "class", "residential", "suburb", "neighbourhood"] + ] + """.trimIndent() + + val filter = Json.decodeFromString(json) + assertTrue(filter is Filter.All) + assertEquals(1, filter.filters.size) + + val inFilter = filter.filters[0] + assertTrue(inFilter is Filter.InList) + assertTrue(inFilter.key is FilterOperand.Get) + assertEquals("class", inFilter.key.property) + assertEquals(3, inFilter.values.size) + assertTrue(inFilter.values.all { it is FilterOperand.Literal }) + assertEquals("residential", (inFilter.values[0] as FilterOperand.Literal).value) + assertEquals("suburb", (inFilter.values[1] as FilterOperand.Literal).value) + assertEquals("neighbourhood", (inFilter.values[2] as FilterOperand.Literal).value) + } + + @Test + fun testParseComplexFilterJson() { + val json = """ + [ + "all", + ["==", ["get", "type"], "Point"], + ["in", ["get", "class"], "bar", "cafe", "restaurant"], + ["has", "name"] + ] + """.trimIndent() + + val filter = Json.decodeFromString(json) + assertTrue(filter is Filter.All) + assertEquals(3, filter.filters.size) + + val typeFilter = filter.filters[0] + assertTrue(typeFilter is Filter.Eq) + assertTrue(typeFilter.left is FilterOperand.Get) + assertEquals("type", (typeFilter.left).property) + assertTrue(typeFilter.right is FilterOperand.Literal) + assertEquals("Point", (typeFilter.right).value) + + val inFilter = filter.filters[1] + assertTrue(inFilter is Filter.InList) + assertTrue(inFilter.key is FilterOperand.Get) + assertEquals("class", (inFilter.key).property) + assertEquals(3, inFilter.values.size) + assertTrue(inFilter.values.all { it is FilterOperand.Literal }) + assertEquals("bar", (inFilter.values[0] as FilterOperand.Literal).value) + assertEquals("cafe", (inFilter.values[1] as FilterOperand.Literal).value) + assertEquals("restaurant", (inFilter.values[2] as FilterOperand.Literal).value) + + val hasFilter = filter.filters[2] + assertTrue(hasFilter is Filter.Has) + assertEquals("name", hasFilter.key) + } + + @Test + fun testParseNestedFilterJson() { + val json = """ + [ + "all", + ["any", ["==", ["get", "class"], "primary"], ["==", ["get", "class"], "secondary"]], + ["!in", ["get", "class"], "bridge", "tunnel"] + ] + """.trimIndent() + + val filter = Json.decodeFromString(json) + assertTrue(filter is Filter.All) + assertEquals(2, filter.filters.size) + + val anyFilter = filter.filters[0] + assertTrue(anyFilter is Filter.AnyOf) + assertEquals(2, anyFilter.filters.size) + + val notInFilter = filter.filters[1] + assertTrue(notInFilter is Filter.NotInList) + assertTrue(notInFilter.key is FilterOperand.Get) + assertEquals("class", (notInFilter.key).property) + assertEquals(2, notInFilter.values.size) + assertTrue(notInFilter.values.all { it is FilterOperand.Literal }) + assertEquals("bridge", (notInFilter.values[0] as FilterOperand.Literal).value) + assertEquals("tunnel", (notInFilter.values[1] as FilterOperand.Literal).value) + } + + @Test + fun testParseTypeAndClassFilter() { + val json = """ + [ + "all", + ["==", "${TYPE_PROPERTY}", "Polygon"], + ["in", "class", "industrial", "garages", "dam"] + ] + """.trimIndent() + + val filter = Json.decodeFromString(json) + assertTrue(filter is Filter.All) + assertEquals(2, filter.filters.size) + + val typeFilter = filter.filters[0] + assertTrue(typeFilter is Filter.Eq) + assertTrue(typeFilter.left is FilterOperand.Get) + assertEquals(TYPE_PROPERTY, (typeFilter.left).property) + assertTrue(typeFilter.right is FilterOperand.Literal) + assertEquals("Polygon", (typeFilter.right).value) + + val inFilter = filter.filters[1] + assertTrue(inFilter is Filter.InList) + assertTrue(inFilter.key is FilterOperand.Get) + assertEquals("class", (inFilter.key).property) + assertEquals(3, inFilter.values.size) + assertTrue(inFilter.values.all { it is FilterOperand.Literal }) + assertEquals("industrial", (inFilter.values[0] as FilterOperand.Literal).value) + assertEquals("garages", (inFilter.values[1] as FilterOperand.Literal).value) + assertEquals("dam", (inFilter.values[2] as FilterOperand.Literal).value) + } + + @Test + fun testParseRiverBrunnelFilter() { + val json = """ + [ + "all", + ["in", "class", "river", "stream", "canal"], + ["==", "brunnel", "tunnel"] + ] + """.trimIndent() + + val filter = Json.decodeFromString(json) + assertTrue(filter is Filter.All) + assertEquals(2, filter.filters.size) + + val inFilter = filter.filters[0] + assertTrue(inFilter is Filter.InList) + assertTrue(inFilter.key is FilterOperand.Get) + assertEquals("class", (inFilter.key).property) + assertEquals(3, inFilter.values.size) + assertTrue(inFilter.values.all { it is FilterOperand.Literal }) + assertEquals("river", (inFilter.values[0] as FilterOperand.Literal).value) + assertEquals("stream", (inFilter.values[1] as FilterOperand.Literal).value) + assertEquals("canal", (inFilter.values[2] as FilterOperand.Literal).value) + + val eqFilter = filter.filters[1] + assertTrue(eqFilter is Filter.Eq) + assertTrue(eqFilter.left is FilterOperand.Get) + assertEquals("brunnel", (eqFilter.left).property) + assertTrue(eqFilter.right is FilterOperand.Literal) + assertEquals("tunnel", (eqFilter.right).value) + } +} \ 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..c6be0a69 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleBright.kt @@ -0,0 +1,134 @@ +package ovh.plrapps.mapcompose.vector.spec.style + +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 ovh.plrapps.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.Expr +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, style.bearing) + assertEquals(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) + val interpolateExpr = landuseColor + assertTrue(interpolateExpr.expr is Expr.Interpolate) + val landuseStops = interpolateExpr.expr.stops + assertEquals(2, landuseStops.size) + assertEquals(Pair(12.0, Expr.Constant(Color.hsl(30f, 0.19f, 0.9f, 0.4f))), landuseStops[0]) + assertEquals(Pair(16.0, Expr.Constant(Color.hsl(30f, 0.19f, 0.9f, 0.2f))), landuseStops[1]) + + 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) + val lineWidthExpr = lineWidth + assertTrue(lineWidthExpr.expr is Expr.Interpolate) + val waterwayStops = lineWidthExpr.expr.stops + assertEquals(2, waterwayStops.size) + assertEquals(Pair(13.0, Expr.Constant(0.5)), waterwayStops[0]) + assertEquals(Pair(20.0, Expr.Constant(6.0)), waterwayStops[1]) + + 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..c31c8315 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleSimple.kt @@ -0,0 +1,127 @@ +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.RawSource +import org.jetbrains.compose.resources.ExperimentalResourceApi +import ovh.plrapps.library.generated.resources.Res +import ovh.plrapps.mapcompose.vector.spec.style.props.Expr +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, style.bearing) + assertEquals(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) + val lineWidthExpr = lineWidth + assertTrue(lineWidthExpr.expr is Expr.Interpolate) + val stops = lineWidthExpr.expr.stops + assertEquals(4, stops.size) + assertEquals(Pair(0.0, Expr.Constant(2.0)), stops[0]) + assertEquals(Pair(6.0, Expr.Constant(6.0)), stops[1]) + assertEquals(Pair(14.0, Expr.Constant(9.0)), stops[2]) + assertEquals(Pair(22.0, Expr.Constant(18.0)), stops[3]) + 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) + val matchExpr = fillColor + assertTrue(matchExpr.expr is Expr.Match) + val match = matchExpr.expr + assertNotNull(match.input) + assertTrue(match.input is Expr.Get<*>) + assertEquals("ADM0_A3", ((match.input).property as Expr.Constant).value) + assertTrue(match.branches.isNotEmpty()) + val firstBranch = match.branches[0] + println("firstBranch.first = ${firstBranch.first}") + println("firstBranch.second = ${firstBranch.second}") + println("firstBranch.second::class = ${firstBranch.second::class}") + assertTrue((firstBranch.first as List<*>).map { it.toString() }.contains("ARM")) + assertEquals(Color(0xFFD6C7FF), (firstBranch.second as Expr.Constant).value) + assertEquals(Color(0xFFEAB38F), (match.elseExpr as Expr.Constant).value) + + 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) + val textFieldExpr = textField + println("textFieldExpr.expr = ${textFieldExpr.expr}") + assertTrue(textFieldExpr.expr is Expr.Interpolate) + + val textFieldStops = textFieldExpr.expr.stops + assertEquals(2, textFieldStops.size) + assertEquals(Pair(2.0, Expr.Constant("{ABBREV}")), textFieldStops[0]) + assertEquals(Pair(4.0, Expr.Constant("{NAME}")), textFieldStops[1]) + } +} 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..e97cd313 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/TestParseStyleStreetV2.kt @@ -0,0 +1,126 @@ +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 ovh.plrapps.library.generated.resources.Res +import ovh.plrapps.mapcompose.vector.spec.style.props.Expr +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 + assertTrue(bgColorExpr.expr is Expr.Interpolate) + val bgStops = bgColorExpr.expr.stops + assertEquals(2, bgStops.size) + assertEquals(Pair(6.0, Expr.Constant(Color.hsl( 47F, 0.79F, 0.94F))), bgStops[0]) + assertEquals(Pair(14.0, Expr.Constant(Color.hsl(42F, 0.49f, 0.93f))), bgStops[1]) + + 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<*> + assertTrue(meadowOpacityExpr.expr is Expr.Interpolate) + val meadowOpacityStops = meadowOpacityExpr.expr.stops + assertEquals(2, meadowOpacityStops.size) + assertEquals(Pair(0.0, Expr.Constant(1.0)), meadowOpacityStops[0]) + assertEquals(Pair(8.0, Expr.Constant(0.1)), meadowOpacityStops[1]) + + 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/props/ConcatExpressionTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ConcatExpressionTest.kt new file mode 100644 index 00000000..2c17bd5c --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ConcatExpressionTest.kt @@ -0,0 +1,92 @@ +package ovh.plrapps.mapcompose.vector.spec.style.props + +import ovh.plrapps.mapcompose.vector.spec.style.serializers.ExpressionOrValueSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ConcatExpressionTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `test concat expression with constant arrays`() { + val expr = Expr.Concat( + left = Expr.Constant(listOf("a", "b")), + right = Expr.Constant(listOf("c", "d")) + ) + + val result = expr.evaluate(emptyMap(), null) + assertTrue(result is List<*>) + assertEquals(4, result.size) + assertEquals(listOf("a", "b", "c", "d"), result) + } + + @Test + fun `test concat expression with get arrays`() { + val properties = mapOf( + "array1" to listOf("x", "y"), + "array2" to listOf("z", "w") + ) + + val expr = Expr.Concat( + left = Expr.Get(Expr.Constant("array1")), + right = Expr.Get(Expr.Constant("array2")) + ) + + val result = expr.evaluate(properties, null) + assertTrue(result is List<*>) + assertEquals(4, result.size) + assertEquals(listOf("x", "y", "z", "w"), result) + } + + @Test + fun `test concat expression with mixed types`() { + val properties = mapOf( + "numbers" to listOf(1, 2), + "strings" to listOf("a", "b") + ) + + val expr = Expr.Concat( + left = Expr.Get(Expr.Constant("numbers")), + right = Expr.Get(Expr.Constant("strings")) + ) + + val result = expr.evaluate(properties, null) + assertTrue(result is List<*>) + assertEquals(4, result.size) + assertEquals(listOf(1, 2, "a", "b"), result) + } + + @Test + fun `test concat expression with null values`() { + val expr = Expr.Concat( + left = Expr.Get(Expr.Constant("nonexistent1")), + right = Expr.Get(Expr.Constant("nonexistent2")) + ) + + val result = expr.evaluate(emptyMap(), null) + assertEquals(null, result) + } + + @Test + fun `test concat expression deserialization`() { + val jsonStr = """["concat",["get","array1"],["get","array2"]]""" + val deserialized = json.decodeFromString( + ExpressionOrValueSerializer(ListSerializer(String.serializer())), + jsonStr + ) + + assertTrue(deserialized is ExpressionOrValue.Expression<*>) + val expression = deserialized as ExpressionOrValue.Expression> + assertTrue(expression.expr is Expr.Concat<*>) + + val concatExpr = expression.expr as Expr.Concat + assertTrue(concatExpr.left is Expr.Get<*>) + assertTrue(concatExpr.right is Expr.Get<*>) + assertEquals("array1", ((concatExpr.left as Expr.Get<*>).property as Expr.Constant).value) + assertEquals("array2", ((concatExpr.right as Expr.Get<*>).property as Expr.Constant).value) + } +} \ No newline at end of file diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrValueColorInterpolateTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrValueColorInterpolateTest.kt new file mode 100644 index 00000000..48aea621 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrValueColorInterpolateTest.kt @@ -0,0 +1,103 @@ +package ovh.plrapps.mapcompose.vector.spec.style.props + +import ovh.plrapps.mapcompose.vector.data.json +import ovh.plrapps.mapcompose.vector.spec.style.serializers.ColorSerializer +import ovh.plrapps.mapcompose.vector.spec.style.serializers.ExpressionOrValueSerializer +import kotlinx.serialization.builtins.serializer +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.assertEquals + +class ExpressionOrValueColorInterpolateTest { + @Test + fun `deserialize interpolate with case`() { + val jsonStr = """["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]]""" + val exprOrValue = json.decodeFromString(ExpressionOrValueSerializer(Float.serializer()), jsonStr) + // TODO, need to finish + assertTrue(exprOrValue is ExpressionOrValue.Expression) + } + + @Test + fun `deserialize interpolate with match and colors`() { + val jsonStr = """ + [ + "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%)" + ] + ] + """.trimIndent() + + val exprOrValue = json.decodeFromString(ExpressionOrValueSerializer(ColorSerializer), jsonStr) + exprOrValue.process() + assertTrue(exprOrValue is ExpressionOrValue.Expression) + val expr = (exprOrValue).expr + assertTrue(expr is Expr.Interpolate) + val interpolate = expr + assertEquals(2, interpolate.stops.size) + interpolate.stops.forEach { (_, value) -> + assertTrue(value is Expr.Match<*> || value is Expr.Constant<*>) + } + } + + @Test + fun `deserialize step expression`() { + val jsonStr = """["step",["zoom"],1,9,["match",["get","class"],"quarry",0,1],10,1]""" + val exprOrValue = json.decodeFromString(ExpressionOrValueSerializer(Float.serializer()), jsonStr) + + assertTrue(exprOrValue is ExpressionOrValue.Expression) + val expr = (exprOrValue as ExpressionOrValue.Expression<*>).expr + assertTrue(expr is Expr.Step) + + val step = expr as Expr.Step<*> + assertEquals(Expr.Zoom, step.input) + assertEquals(Expr.Constant(1.0), step.default) + assertEquals(2, step.stops.size) + + val (stop1, value1) = step.stops[0] + assertEquals(9.0, stop1) + assertTrue(value1 is Expr.Match<*>) + + val (stop2, value2) = step.stops[1] + assertEquals(10.0, stop2) + assertTrue(value2 is Expr.Constant<*>) + assertEquals(1.0, (value2 as Expr.Constant<*>).value) + } + + @Test + fun `deserialize interpolate and process`() { + val jsonStr = """{ + "stops": [ + [ + 2, + "{ABBREV}" + ], + [ + 4, + "{NAME}" + ] + ] + }""" + val featureProperties = mapOf("NAME" to "United Kingdom", "ABBREV" to "U.K.") + val textField = json.decodeFromString(ExpressionOrValueSerializer(String.serializer()), jsonStr) + assertEquals("{ABBREV}", textField.process(featureProperties, zoom = 2.0)) + assertEquals("{ABBREV}", textField.process(featureProperties, zoom = 3.0)) + assertEquals("{NAME}", textField.process(featureProperties, zoom = 4.0)) + assertEquals("{NAME}", textField.process(featureProperties, zoom = 5.0)) + } +} \ No newline at end of file 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..dc9d1979 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/ExpressionOrValueTest.kt @@ -0,0 +1,420 @@ +package ovh.plrapps.mapcompose.vector.spec.style.props + +import androidx.compose.ui.graphics.Color +import ovh.plrapps.mapcompose.vector.spec.style.serializers.ExpressionOrValueSerializer +import kotlinx.serialization.json.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.assertFalse +import kotlinx.serialization.builtins.serializer +import ovh.plrapps.mapcompose.vector.data.json + +class ExpressionOrValueTest { + @Test + fun `Value should return its value`() { + val value: ExpressionOrValue = ExpressionOrValue.Value(42) + assertEquals(42, value.process()) + } + + @Test + fun `Expression with Get should return property value`() { + val expr: ExpressionOrValue = ExpressionOrValue.Expression(Expr.Get(Expr.Constant("name"))) + val properties = mapOf("name" to "test") + assertEquals("test", expr.process(featureProperties = properties)) + } + + @Test + fun `Expression with Match should return correct value`() { + val matchExpr = Expr.Match( + input = Expr.Get(Expr.Constant("type")), + branches = listOf( + listOf("residential") to Expr.Constant("local") + ), + elseExpr = Expr.Constant("other") + ) + val expr: ExpressionOrValue = ExpressionOrValue.Expression(matchExpr) + + val matchingProps = mapOf("type" to "residential") + assertEquals("local", expr.process(featureProperties = matchingProps)) + + val nonMatchingProps = mapOf("type" to "commercial") + assertEquals("other", expr.process(featureProperties = nonMatchingProps)) + } + + @Test + fun `Expression with ZoomStops should return correct value`() { + val stops = listOf( + 10.0 to Expr.Constant("small"), + 14.0 to Expr.Constant("medium"), + 18.0 to Expr.Constant("large") + ) + val expr: ExpressionOrValue = ExpressionOrValue.Expression(Expr.ZoomStops(stops)) + + assertEquals("small", expr.process(zoom = 8.0)) + assertEquals("small", expr.process(zoom = 12.0)) + assertEquals("medium", expr.process(zoom = 16.0)) + assertEquals("large", expr.process(zoom = 20.0)) + } + + @Test + fun `Expression with Interpolate Linear should interpolate numbers`() { + val interpolateExpr = Expr.Interpolate( + interpolation = InterpolationType.Linear, + input = Expr.Zoom, + stops = listOf( + 0.0 to Expr.Constant(0.0), + 10.0 to Expr.Constant(100.0) + ) + ) + val expr: ExpressionOrValue = ExpressionOrValue.Expression(interpolateExpr) + + // value Interpolated + assertEquals(50.0, expr.process(zoom = 5.0)) + } + + @Test + fun `Expression with Constant should return its value`() { + val expr: ExpressionOrValue = ExpressionOrValue.Expression(Expr.Constant("test")) + assertEquals("test", expr.process()) + } + + @Test + fun `Expression with Raw should return null`() { + val expr: ExpressionOrValue = + ExpressionOrValue.Expression(Expr.Raw(JsonNull)) + assertNull(expr.process()) + } + + @Test + fun `process should handle null properties and zoom`() { + val expr: ExpressionOrValue = ExpressionOrValue.Expression(Expr.Get(Expr.Constant("name"))) + assertNull(expr.process()) + assertNull(expr.process(featureProperties = emptyMap())) + } + + @Test + fun testNotEqualsExpressionWithConstants() { + val expr = Expr.NotEquals( + left = Expr.Constant("value1"), + right = Expr.Constant("value2") + ) + + val result = expr.evaluate(emptyMap(), 0.0) + assertTrue(result == true) + + val expr2 = Expr.NotEquals( + left = Expr.Constant("same"), + right = Expr.Constant("same") + ) + + val result2 = expr2.evaluate(emptyMap(), 0.0) + assertTrue(result2 == false) + } + + @Test + fun testNotEqualsExpressionWithGet() { + val properties = mapOf( + "prop1" to "value1", + "prop2" to "value2", + "prop3" to "value1" + ) + + val expr1 = Expr.NotEquals( + left = Expr.Get(Expr.Constant("prop1")), + right = Expr.Get(Expr.Constant("prop2")) + ) + assertTrue(expr1.evaluate(properties, 0.0) == true) + + val expr2 = Expr.NotEquals( + left = Expr.Get(Expr.Constant("prop1")), + right = Expr.Get(Expr.Constant("prop3")) + ) + assertTrue(expr2.evaluate(properties, 0.0) == false) + } + + @Test + fun testNotEqualsExpressionWithMixedTypes() { + val properties = mapOf( + "number" to 42, + "text" to "42" + ) + + val expr = Expr.NotEquals( + left = Expr.Get(Expr.Constant("number")), + right = Expr.Get(Expr.Constant("text")) + ) + assertTrue(expr.evaluate(properties, 0.0) == true) + } + + @Test + fun testNotEqualsExpressionWithNullValues() { + val properties = mapOf( + "existing" to "value", + "nullValue" to null + ) + + val expr1 = Expr.NotEquals( + left = Expr.Get(Expr.Constant("existing")), + right = Expr.Get(Expr.Constant("nonexistent")) + ) + assertTrue(expr1.evaluate(properties, 0.0) == true) + + val expr2 = Expr.NotEquals( + left = Expr.Get(Expr.Constant("nullValue")), + right = Expr.Get(Expr.Constant("nonexistent")) + ) + assertTrue(expr2.evaluate(properties, 0.0) == false) + } + + @Test + fun testEqualsExpressionWithConstants() { + val expr = Expr.Equals( + left = Expr.Constant("value1"), + right = Expr.Constant("value2") + ) + + val result = expr.evaluate(emptyMap(), 0.0) + assertTrue(result == false) + + val expr2 = Expr.Equals( + left = Expr.Constant("same"), + right = Expr.Constant("same") + ) + + val result2 = expr2.evaluate(emptyMap(), 0.0) + assertTrue(result2 == true) + } + + @Test + fun testEqualsExpressionWithGet() { + val properties = mapOf( + "prop1" to "value1", + "prop2" to "value2", + "prop3" to "value1" + ) + + val expr1 = Expr.Equals( + left = Expr.Get(Expr.Constant("prop1")), + right = Expr.Get(Expr.Constant("prop2")) + ) + assertTrue(expr1.evaluate(properties, 0.0) == false) + + val expr2 = Expr.Equals( + left = Expr.Get(Expr.Constant("prop1")), + right = Expr.Get(Expr.Constant("prop3")) + ) + assertTrue(expr2.evaluate(properties, 0.0) == true) + } + + @Test + fun testEqualsExpressionWithMixedTypes() { + val properties = mapOf( + "number" to 42, + "text" to "42" + ) + + val expr = Expr.Equals( + left = Expr.Get(Expr.Constant("number")), + right = Expr.Get(Expr.Constant("text")) + ) + assertTrue(expr.evaluate(properties, 0.0) == false) + } + + @Test + fun testEqualsExpressionWithNullValues() { + val properties = mapOf( + "existing" to "value", + "nullValue" to null + ) + + val expr1 = Expr.Equals( + left = Expr.Get(Expr.Constant("existing")), + right = Expr.Get(Expr.Constant("nonexistent")) + ) + assertTrue(expr1.evaluate(properties, 0.0) == false) + + val expr2 = Expr.Equals( + left = Expr.Get(Expr.Constant("nullValue")), + right = Expr.Get(Expr.Constant("nonexistent")) + ) + assertTrue(expr2.evaluate(properties, 0.0) == true) + } + + @Test + fun testToBooleanExpression() { + val nullExpr = Expr.ToBoolean(Expr.Constant(null)) + assertTrue(nullExpr.evaluate(null, null) == false) + + val emptyStringExpr = Expr.ToBoolean(Expr.Constant("")) + assertTrue(emptyStringExpr.evaluate(null, null) == false) + + val nonEmptyStringExpr = Expr.ToBoolean(Expr.Constant("test")) + assertTrue(nonEmptyStringExpr.evaluate(null, null) == true) + + val zeroExpr = Expr.ToBoolean(Expr.Constant(0)) + assertTrue(zeroExpr.evaluate(null, null) == false) + + val nonZeroExpr = Expr.ToBoolean(Expr.Constant(42)) + assertTrue(nonZeroExpr.evaluate(null, null) == true) + + val nanExpr = Expr.ToBoolean(Expr.Constant(Double.NaN)) + assertTrue(nanExpr.evaluate(null, null) == false) + + val trueExpr = Expr.ToBoolean(Expr.Constant(true)) + assertTrue(trueExpr.evaluate(null, null) == true) + + val falseExpr = Expr.ToBoolean(Expr.Constant(false)) + assertTrue(falseExpr.evaluate(null, null) == false) + + val objectExpr = Expr.ToBoolean(Expr.Constant(mapOf("key" to "value"))) + assertTrue(objectExpr.evaluate(null, null) == true) + } + + @Test + fun testToStringExpression() { + val nullExpr = Expr.ToString(Expr.Constant(null)) + assertEquals("", nullExpr.evaluate(null, null)) + + val emptyStringExpr = Expr.ToString(Expr.Constant("")) + assertEquals("", emptyStringExpr.evaluate(null, null)) + + val nonEmptyStringExpr = Expr.ToString(Expr.Constant("test")) + assertEquals("test", nonEmptyStringExpr.evaluate(null, null)) + + val trueExpr = Expr.ToString(Expr.Constant(true)) + assertEquals("true", trueExpr.evaluate(null, null)) + + val falseExpr = Expr.ToString(Expr.Constant(false)) + assertEquals("false", falseExpr.evaluate(null, null)) + + val numberExpr = Expr.ToString(Expr.Constant(42)) + assertEquals("42", numberExpr.evaluate(null, null)) + + val doubleExpr = Expr.ToString(Expr.Constant(3.14)) + assertEquals("3.14", doubleExpr.evaluate(null, null)) + + val colorExpr = Expr.ToString(Expr.Constant(Color(255, 0, 0, 128))) + assertEquals("rgba(255,0,0,0.5)", colorExpr.evaluate(null, null)) + + val objectExpr = Expr.ToString(Expr.Constant(mapOf("key" to "value"))) + assertEquals("""{"key":"value"}""", objectExpr.evaluate(null, null)) + + val arrayExpr = Expr.ToString(Expr.Constant(listOf(1, 2, 3))) + assertEquals("[1,2,3]", arrayExpr.evaluate(null, null)) + } + + @Test + fun `isExpression returns true for array with string first element`() { + val input = JsonArray(listOf(JsonPrimitive("get"), JsonPrimitive("name"))) + assertTrue(ExpressionOrValue.isExpression(input)) + } + + @Test + fun `isExpression returns false for empty array`() { + val input = JsonArray(emptyList()) + assertFalse(ExpressionOrValue.isExpression(input)) + } + + @Test + fun `isExpression returns false for array with non-string first element`() { + val input = JsonArray(listOf(JsonPrimitive(1), JsonPrimitive("name"))) + assertFalse(ExpressionOrValue.isExpression(input)) + } + + @Test + fun `isExpression returns true for object with stops property`() { + val input = JsonObject(mapOf("stops" to JsonArray(emptyList()))) + assertTrue(ExpressionOrValue.isExpression(input)) + } + + @Test + fun `isExpression returns false for object without stops property`() { + val input = JsonObject(mapOf("color" to JsonPrimitive("red"))) + assertFalse(ExpressionOrValue.isExpression(input)) + } + + @Test + fun `isExpression returns false for primitive value`() { + val input = JsonPrimitive("test") + assertFalse(ExpressionOrValue.isExpression(input)) + } + + @Test + fun `isExpression work 001`() { + val input = + ("""[ "match", [ "get", "class" ], [ "college", "childcare", "dancing_school", "driving_school", "kindergarten", "school", "university" ], [ "get", "class" ], [ "case", [ "has", "class" ], "", "dot" ] ]""") + val expr = json.decodeFromString>(input) + val feature = mapOf( + "class" to "school", + "name" to "Детский сад № 14", + "name:latin" to "Detskij sad № 14", + "name:nonlatin" to "Детский сад № 14", + "name_de" to "Детский сад № 14", + "name_en" to "Детский сад № 14", + "name_int" to "Detskij sad № 14", + "rank" to 11, + "subclass" to "kindergarten", + "\$type" to "Point" + ) + val result = expr.process(feature, 15.0) + assertEquals("school", result) + } + + @Test + fun `test case expression with equals condition`() { + val jsonStr = """ + [ + "case", + [ + "==", + [ + "get", + "brunnel" + ], + "tunnel" + ], + 0.7, + 1 + ] + """.trimIndent() + + val json = Json { ignoreUnknownKeys = true } + val exprOrValue = json.decodeFromString(ExpressionOrValueSerializer(Double.serializer()), jsonStr) + + assertTrue(exprOrValue is ExpressionOrValue.Expression) + val expr = (exprOrValue).expr + + assertTrue(expr is Expr.Case) + val caseExpr = expr + + assertEquals(1, caseExpr.conditions.size) + + val (condition, result) = caseExpr.conditions[0] + assertTrue(condition is Expr.Equals<*, *>) + val equalsExpr = condition + + assertTrue(equalsExpr.left is Expr.Get<*>) + val getExpr = equalsExpr.left + assertEquals("brunnel", (getExpr.property as Expr.Constant).value) + + assertTrue(equalsExpr.right is Expr.Constant<*>) + val constantExpr = equalsExpr.right + assertEquals("tunnel", constantExpr.value) + + assertTrue(result is Expr.Constant) + val resultExpr = result + assertEquals(0.7, resultExpr.value) + + assertTrue(caseExpr.default is Expr.Constant) + val defaultExpr = caseExpr.default + assertEquals(1.0, defaultExpr.value) + + val tunnelProps = mapOf("brunnel" to "tunnel") + val nonTunnelProps = mapOf("brunnel" to "bridge") + + assertEquals(0.7, exprOrValue.process(featureProperties = tunnelProps)) + assertEquals(1.0, exprOrValue.process(featureProperties = nonTunnelProps)) + } +} \ No newline at end of file diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/IndexOfExpressionTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/IndexOfExpressionTest.kt new file mode 100644 index 00000000..75e7ac14 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/IndexOfExpressionTest.kt @@ -0,0 +1,80 @@ +package ovh.plrapps.mapcompose.vector.spec.style.props + +import ovh.plrapps.mapcompose.vector.spec.style.serializers.ExpressionOrValueSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.builtins.serializer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class IndexOfExpressionTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `test index-of expression with constant array`() { + val expr = Expr.IndexOf( + array = Expr.Constant(listOf("a", "b", "c")), + value = Expr.Constant("b") + ) + + val result = expr.evaluate(emptyMap(), null) + assertEquals(1, result) + } + + @Test + fun `test index-of expression with get array`() { + val properties = mapOf( + "array" to listOf("x", "y", "z"), + "value" to "y" + ) + + val expr = Expr.IndexOf( + array = Expr.Get>(Expr.Constant("array")), + value = Expr.Get(Expr.Constant("value")) + ) + + val result = expr.evaluate(properties, null) + assertEquals(1, result) + } + + @Test + fun `test index-of expression with non-existent value`() { + val expr = Expr.IndexOf( + array = Expr.Constant(listOf("a", "b", "c")), + value = Expr.Constant("d") + ) + + val result = expr.evaluate(emptyMap(), null) + assertEquals(-1, result) + } + + @Test + fun `test index-of expression with null values`() { + val expr = Expr.IndexOf( + array = Expr.Get>(Expr.Constant("nonexistent")), + value = Expr.Constant("value") + ) + + val result = expr.evaluate(emptyMap(), null) + assertEquals(null, result) + } + + @Test + fun `test index-of expression deserialization`() { + val jsonStr = """["index-of",["get","array"],"value"]""" + val deserialized = json.decodeFromString( + ExpressionOrValueSerializer(Int.serializer()), + jsonStr + ) + + assertTrue(deserialized is ExpressionOrValue.Expression<*>) + val expression = deserialized as ExpressionOrValue.Expression + assertTrue(expression.expr is Expr.IndexOf<*>) + + val indexOfExpr = expression.expr as Expr.IndexOf + assertTrue(indexOfExpr.array is Expr.Get<*>) + assertTrue(indexOfExpr.value is Expr.Constant<*>) + assertEquals("array", ((indexOfExpr.array as Expr.Get<*>).property as Expr.Constant).value) + assertEquals("value", (indexOfExpr.value as Expr.Constant<*>).value) + } +} \ No newline at end of file diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/StepExprTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/StepExprTest.kt new file mode 100644 index 00000000..e67cf8c0 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/StepExprTest.kt @@ -0,0 +1,30 @@ +package ovh.plrapps.mapcompose.vector.spec.style.props + +import kotlin.test.Test +import kotlin.test.assertEquals + +class StepExprTest { + @Test + fun testStepEvaluate() { + val expr = Expr.Step( + input = Expr.Zoom, + default = Expr.Constant(0), + stops = listOf( + 14.0 to Expr.Constant(10), + 15.0 to Expr.Constant(20), + 16.0 to Expr.Constant(30), + 17.0 to Expr.Constant(40) + ) + ) + // input = 13 -> default + assertEquals(0, expr.evaluate(null, 13.0)) + // input = 14 -> match1 (10) + assertEquals(10, expr.evaluate(null, 14.0)) + // input = 15 -> match2 (20) + assertEquals(20, expr.evaluate(null, 15.0)) + // input = 16 -> match3 (30) + assertEquals(30, expr.evaluate(null, 16.0)) + // input = 18 -> last + assertEquals(40, expr.evaluate(null, 18.0)) + } +} \ No newline at end of file diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/StringCaseExpressionTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/StringCaseExpressionTest.kt new file mode 100644 index 00000000..87f48458 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/props/StringCaseExpressionTest.kt @@ -0,0 +1,72 @@ +package ovh.plrapps.mapcompose.vector.spec.style.props + +import ovh.plrapps.mapcompose.vector.spec.style.serializers.ExpressionOrValueSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.builtins.serializer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class StringCaseExpressionTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `test downcase expression with constant string`() { + val expr = Expr.Downcase(Expr.Constant("Hello World")) + val result = expr.evaluate(emptyMap(), null) + assertEquals("hello world", result) + } + + @Test + fun `test downcase expression with get string`() { + val expr = Expr.Downcase(Expr.Get(Expr.Constant("name"))) + val result = expr.evaluate(mapOf("name" to "Hello World"), null) + assertEquals("hello world", result) + } + + @Test + fun `test downcase expression with null value`() { + val expr = Expr.Downcase(Expr.Get(Expr.Constant("name"))) + val result = expr.evaluate(emptyMap(), null) + assertNull(result) + } + + @Test + fun `test upcase expression with constant string`() { + val expr = Expr.Upcase(Expr.Constant("Hello World")) + val result = expr.evaluate(emptyMap(), null) + assertEquals("HELLO WORLD", result) + } + + @Test + fun `test upcase expression with get string`() { + val expr = Expr.Upcase(Expr.Get(Expr.Constant("name"))) + val result = expr.evaluate(mapOf("name" to "Hello World"), null) + assertEquals("HELLO WORLD", result) + } + + @Test + fun `test upcase expression with null value`() { + val expr = Expr.Upcase(Expr.Get(Expr.Constant("name"))) + val result = expr.evaluate(emptyMap(), null) + assertNull(result) + } + + @Test + fun `test serialization and deserialization of downcase expression`() { + val original = ExpressionOrValue.Expression(Expr.Downcase(Expr.Constant("Hello World")), source="""["downcase","Hello World"]""") + val serializer = ExpressionOrValueSerializer(String.serializer()) + val jsonString = json.encodeToString(serializer, original) + val deserialized = json.decodeFromString(serializer, jsonString) + assertEquals(original, deserialized) + } + + @Test + fun `test serialization and deserialization of upcase expression`() { + val original = ExpressionOrValue.Expression(Expr.Upcase(Expr.Constant("Hello World")), source = """["upcase","Hello World"]""") + val serializer = ExpressionOrValueSerializer(String.serializer()) + val jsonString = json.encodeToString(serializer, original) + val deserialized = json.decodeFromString(serializer, jsonString) + assertEquals(original, deserialized) + } +} \ No newline at end of file diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/ExpressionOrValueSerializerTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/ExpressionOrValueSerializerTest.kt new file mode 100644 index 00000000..547f28e6 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/serializers/ExpressionOrValueSerializerTest.kt @@ -0,0 +1,944 @@ +package ovh.plrapps.mapcompose.vector.spec.style.serializers + +import androidx.compose.ui.graphics.Color +import ovh.plrapps.mapcompose.vector.data.json +import ovh.plrapps.mapcompose.vector.spec.style.props.Expr +import ovh.plrapps.mapcompose.vector.spec.style.props.ExpressionOrValue +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlin.math.PI +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +class ExpressionOrValueSerializerTest { + @Test + fun `test serialize simple int value`() { + val value = ExpressionOrValue.Value(42) + val serialized = json.encodeToString(ExpressionOrValueSerializer(Int.serializer()), value) + assertEquals("42", serialized) + } + + @Test + fun `test serialize color value`() { + val value = ExpressionOrValue.Value(Color(0xFF0000FF)) + val serialized = json.encodeToString(ExpressionOrValueSerializer(ColorSerializer), value) + assertEquals("#0000FF", serialized) + } + + @Test + fun `test deserialize simple int value`() { + val jsonStr = "42" + val deserialized = json.decodeFromString( + ExpressionOrValueSerializer(Int.serializer()), + jsonStr + ) + assertTrue(deserialized is ExpressionOrValue.Value) + assertEquals(42, deserialized.value) + } + + @Test + fun `test deserialize color value`() { + val jsonStr = "#0000FF" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(ColorSerializer), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Value) + assertEquals(Color(0xFF0000FF), deserialized.value) + } + + @Test + fun `test deserialize get expression`() { + val jsonStr = "[\"get\",\"name\"]" + val deserialized = json.decodeFromString( + ExpressionOrValueSerializer(String.serializer()), + jsonStr + ) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.Get<*>) + } + + @Test + fun `test deserialize interpolate expression`() { + val jsonStr = "[\"interpolate\",[\"linear\"],[\"zoom\"],0,0,10,100]" + val deserialized = json.decodeFromString( + ExpressionOrValueSerializer(Double.serializer()), + jsonStr + ) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.Interpolate<*>) + } + + @Test + fun `test deserialize match expression`() { + val jsonStr = "[\"match\",[\"get\",\"type\"],\"residential\",\"local\",\"other\"]" + val deserialized = + json.decodeFromString>(ExpressionOrValueSerializer(String.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.Match<*>) + } + + @Test + fun `test deserialize stops expression`() { + val jsonStr = "{\"stops\":[[0,2],[6,6],[14,9],[22,18]]}" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(Double.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.Interpolate) + + val stops = (deserialized.expr).stops + assertEquals(4, stops.size) + assertEquals(0.0 to Expr.Constant(2.0), stops[0]) + assertEquals(6.0 to Expr.Constant(6.0), stops[1]) + assertEquals(14.0 to Expr.Constant(9.0), stops[2]) + assertEquals(22.0 to Expr.Constant(18.0), stops[3]) + } + + @Test + fun `test deserialize has expression`() { + val jsonStr = "[\"has\",\"name\"]" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(Boolean.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.Has<*>) + assertEquals("name", (deserialized.expr).property) + } + + @Test + fun `test deserialize in expression`() { + val jsonStr = "[\"in\",[\"get\",\"type\"],[\"residential\",\"commercial\"]]" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(Boolean.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.In<*>) + val inExpr = deserialized.expr + assertTrue(inExpr.value is Expr.Get) + assertEquals(listOf("residential", "commercial"), inExpr.array) + } + + @Test + fun `test deserialize case expression`() { + val jsonStr = "[\"case\",[\"has\",\"name\"],\"named\",\"unnamed\"]" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(String.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.Case) + val caseExpr = deserialized.expr + assertEquals(1, caseExpr.conditions.size) + assertTrue(caseExpr.conditions[0].first is Expr.Has<*>) + assertTrue(caseExpr.conditions[0].second is Expr.Constant) + assertTrue(caseExpr.default is Expr.Constant) + } + + @Test + fun `test deserialize coalesce expression`() { + val jsonStr = "[\"coalesce\",[\"get\",\"name\"],[\"get\",\"title\"],\"unnamed\"]" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(String.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.Coalesce) + val coalesceExpr = deserialized.expr + assertEquals(3, coalesceExpr.values.size) + assertTrue(coalesceExpr.values[0] is Expr.Get) + assertTrue(coalesceExpr.values[1] is Expr.Get) + assertTrue(coalesceExpr.values[2] is Expr.Constant) + } + + @Test + fun `test deserialize invalid json`() { + try { + json.decodeFromString>( + ExpressionOrValueSerializer(Int.serializer()), + "invalid" + ) + fail("Expected exception was not thrown") + } catch (e: Exception) { + // Expected + } + } + + @Test + fun testNotEqualsExpressionDeserialization() { + val json = """["!=",["get","property1"],"value"]""" + val expr = Json.decodeFromString( + ExpressionOrValueSerializer(Boolean.serializer()), + json + ) as ExpressionOrValue.Expression + + val result = expr.expr.evaluate(mapOf("property1" to "different"), 0.0) + assertTrue(result == true) + + val result2 = expr.expr.evaluate(mapOf("property1" to "value"), 0.0) + assertTrue(result2 == false) + } + + @Test + fun testNotEqualsExpressionWithConstantsDeserialization() { + val json = """["!=","value1","value2"]""" + val expr = Json.decodeFromString( + ExpressionOrValueSerializer(Boolean.serializer()), + json + ) as ExpressionOrValue.Expression + + val result = expr.expr.evaluate(emptyMap(), 0.0) + assertTrue(result == true) + } + + @Test + fun testEqualsExpressionDeserialization() { + val json = """["==",["get","property1"],"value"]""" + val expr = Json.decodeFromString( + ExpressionOrValueSerializer(Boolean.serializer()), + json + ) as ExpressionOrValue.Expression + + val result = expr.expr.evaluate(mapOf("property1" to "different"), 0.0) + assertTrue(result == false) + + val result2 = expr.expr.evaluate(mapOf("property1" to "value"), 0.0) + assertTrue(result2 == true) + } + + @Test + fun testEqualsExpressionWithConstantsDeserialization() { + val json = """["==","value1","value2"]""" + val expr = Json.decodeFromString( + ExpressionOrValueSerializer(Boolean.serializer()), + json + ) as ExpressionOrValue.Expression + + val result = expr.expr.evaluate(emptyMap(), 0.0) + assertTrue(result == false) + } + + + @Test + fun `test deserialize equals expression`() { + val jsonStr = "[\"==\",[\"get\",\"name\"],\"test\"]" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(Boolean.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.Equals<*, *>) + } + + @Test + fun `test equals expression with constants`() { + val expr = Expr.Equals( + Expr.Constant("test"), + Expr.Constant("test") + ) + assertEquals(true, expr.evaluate(null, null)) + } + + @Test + fun `test deserialize less than expression`() { + val jsonStr = "[\"<\",[\"get\",\"value\"],10]" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(Boolean.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.LessThan<*>) + } + + @Test + fun `test deserialize less than or equal expression`() { + val jsonStr = "[\"<=\",[\"get\",\"value\"],10]" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(Boolean.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.LessThanOrEqual<*>) + } + + @Test + fun `test deserialize greater than expression`() { + val jsonStr = "[\">\",[\"get\",\"value\"],10]" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(Boolean.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.GreaterThan<*>) + } + + @Test + fun `test deserialize greater than or equal expression`() { + val jsonStr = "[\">=\",[\"get\",\"value\"],10]" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(Boolean.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.GreaterThanOrEqual<*>) + } + + @Test + fun `test evaluate less than expression`() { + val expr = Expr.LessThan( + Expr.Get(Expr.Constant("value")), + Expr.Constant(10) + ) + + assertEquals(true, expr.evaluate(mapOf("value" to 5), null)) + + assertEquals(false, expr.evaluate(mapOf("value" to 10), null)) + + assertEquals(false, expr.evaluate(mapOf("value" to 15), null)) + + assertEquals(null, expr.evaluate(emptyMap(), null)) + } + + @Test + fun `test evaluate less than or equal expression`() { + val expr = Expr.LessThanOrEqual( + Expr.Get(Expr.Constant("value")), + Expr.Constant(10) + ) + + assertEquals(true, expr.evaluate(mapOf("value" to 5), null)) + + assertEquals(true, expr.evaluate(mapOf("value" to 10), null)) + + assertEquals(false, expr.evaluate(mapOf("value" to 15), null)) + + assertEquals(null, expr.evaluate(emptyMap(), null)) + } + + @Test + fun `test evaluate greater than expression`() { + val expr = Expr.GreaterThan( + Expr.Get(Expr.Constant("value")), + Expr.Constant(10) + ) + + assertEquals(false, expr.evaluate(mapOf("value" to 5), null)) + + assertEquals(false, expr.evaluate(mapOf("value" to 10), null)) + + assertEquals(true, expr.evaluate(mapOf("value" to 15), null)) + + assertEquals(null, expr.evaluate(emptyMap(), null)) + } + + @Test + fun `test evaluate greater than or equal expression`() { + val expr = Expr.GreaterThanOrEqual( + Expr.Get(Expr.Constant("value")), + Expr.Constant(10) + ) + + assertEquals(false, expr.evaluate(mapOf("value" to 5), null)) + + assertEquals(true, expr.evaluate(mapOf("value" to 10), null)) + + assertEquals(true, expr.evaluate(mapOf("value" to 15), null)) + + assertEquals(null, expr.evaluate(emptyMap(), null)) + } + + @Test + fun `test evaluate string comparison`() { + val expr = Expr.LessThan( + Expr.Get(Expr.Constant("value")), + Expr.Constant("z") + ) + + assertEquals(true, expr.evaluate(mapOf("value" to "a"), null)) + assertEquals(false, expr.evaluate(mapOf("value" to "z"), null)) + assertEquals(false, expr.evaluate(mapOf("value" to "zz"), null)) + } + + @Test + fun `test evaluate mixed type comparison`() { + val expr = Expr.LessThan( + Expr.Get(Expr.Constant("value")), + Expr.Constant(10) + ) + + assertEquals(null, expr.evaluate(mapOf("value" to "string"), null)) + assertEquals(true, expr.evaluate(mapOf("value" to 5), null)) + assertEquals(false, expr.evaluate(mapOf("value" to 15), null)) + } + + @Test + fun `test deserialize all expression`() { + val jsonStr = """["all",["get","prop1"],["get","prop2"]]""" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(Boolean.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.All) + assertEquals(2, (deserialized.expr).conditions.size) + } + + @Test + fun `test deserialize any expression`() { + val jsonStr = """["any",["get","prop1"],["get","prop2"]]""" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(Boolean.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.AnyOf) + assertEquals(2, (deserialized.expr).conditions.size) + } + + @Test + fun `test deserialize not expression`() { + val jsonStr = """["!",["get","prop1"]]""" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(Boolean.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.Not) + } + + @Test + fun `test evaluate all expression`() { + val expr = Expr.All( + listOf( + Expr.Get(Expr.Constant("prop1")), + Expr.Get(Expr.Constant("prop2")) + ) + ) + + assertEquals(true, expr.evaluate(mapOf("prop1" to true, "prop2" to true), null)) + + assertEquals(false, expr.evaluate(mapOf("prop1" to true, "prop2" to false), null)) + + assertEquals(null, expr.evaluate(mapOf("prop1" to true), null)) + + assertEquals(null, Expr.All(emptyList()).evaluate(null, null)) + } + + @Test + fun `test evaluate any expression`() { + val expr = Expr.AnyOf( + listOf( + Expr.Get(Expr.Constant("prop1")), + Expr.Get(Expr.Constant("prop2")) + ) + ) + + assertEquals(true, expr.evaluate(mapOf("prop1" to true, "prop2" to false), null)) + + assertEquals(false, expr.evaluate(mapOf("prop1" to false, "prop2" to false), null)) + + assertEquals(null, expr.evaluate(mapOf("prop1" to false), null)) + + assertEquals(null, Expr.AnyOf(emptyList()).evaluate(null, null)) + } + + @Test + fun `test evaluate not expression`() { + val expr = Expr.Not(Expr.Get(Expr.Constant("prop1"))) + + assertEquals(false, expr.evaluate(mapOf("prop1" to true), null)) + + assertEquals(true, expr.evaluate(mapOf("prop1" to false), null)) + + assertEquals(null, expr.evaluate(emptyMap(), null)) + } + + @Test + fun `test complex logical expressions`() { + val expr = Expr.All( + listOf( + Expr.Not(Expr.Get(Expr.Constant("prop1"))), + Expr.AnyOf( + listOf( + Expr.Get(Expr.Constant("prop2")), + Expr.Get(Expr.Constant("prop3")) + ) + ) + ) + ) + + // prop1=false, prop2=true, prop3=false + assertEquals( + true, expr.evaluate( + mapOf( + "prop1" to false, + "prop2" to true, + "prop3" to false + ), null + ) + ) + + // prop1=true, prop2=false, prop3=false + assertEquals( + false, expr.evaluate( + mapOf( + "prop1" to true, + "prop2" to false, + "prop3" to false + ), null + ) + ) + + // prop1=false, prop2=false, prop3=false + assertEquals( + false, expr.evaluate( + mapOf( + "prop1" to false, + "prop2" to false, + "prop3" to false + ), null + ) + ) + } + + @Test + fun `test deserialize modulo expression`() { + val jsonStr = """["%",["get","value"],3]""" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(Double.serializer()), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + assertTrue(deserialized.expr is Expr.Modulo) + } + + @Test + fun `test evaluate modulo expression`() { + val expr = Expr.Modulo(Expr.Get(Expr.Constant("value")), Expr.Constant(3)) + val exprWithZeroDivisor = Expr.Modulo(Expr.Constant(7), Expr.Constant(0)) + + assertEquals(1.0, expr.evaluate(mapOf("value" to 7), null)) + assertEquals(null, exprWithZeroDivisor.evaluate(emptyMap(), null)) + assertEquals(null, expr.evaluate(mapOf("value" to null), null)) + assertEquals(null, expr.evaluate(mapOf("value" to "not a number"), null)) + } + + @Test + fun `test evaluate modulo with different number types`() { + val expr = Expr.Modulo( + Expr.Get(Expr.Constant("value")), + Expr.Constant(3) + ) + + // Int + assertEquals(1.0, expr.evaluate(mapOf("value" to 7), null)) + + // Double + assertEquals(1.0, expr.evaluate(mapOf("value" to 7.0), null)) + + // Float + assertEquals(1.0, expr.evaluate(mapOf("value" to 7.0f), null)) + + // Long + assertEquals(1.0, expr.evaluate(mapOf("value" to 7L), null)) + } + + @Test + fun `test complex modulo expression`() { + val expr = Expr.Modulo( + Expr.Get(Expr.Constant("value")), + Expr.Modulo( + Expr.Get(Expr.Constant("divisor")), + Expr.Constant(2) + ) + ) + + // value = 7, divisor = 5 + // 7 % (5 % 2) = 7 % 1 = 0 + assertEquals( + 0.0, expr.evaluate( + mapOf( + "value" to 7, + "divisor" to 5 + ), null + ) + ) + + // value = 7, divisor = 4 + // 7 % (4 % 2) = 7 % 0 = null (div 0) + assertEquals( + null, expr.evaluate( + mapOf( + "value" to 7, + "divisor" to 4 + ), null + ) + ) + } + + @Test + fun `test evaluate power expression`() { + val expr = Expr.Power(Expr.Get(Expr.Constant("value")), Expr.Constant(2.0)) + assertEquals(4.0, expr.evaluate(mapOf("value" to 2.0), 0.0)) + assertEquals(9.0, expr.evaluate(mapOf("value" to 3.0), 0.0)) + assertEquals(null, expr.evaluate(mapOf("value" to null), 0.0)) + assertEquals(null, expr.evaluate(mapOf("value" to "not a number"), 0.0)) + } + + @Test + fun `test evaluate abs expression`() { + val expr = Expr.Abs(Expr.Get(Expr.Constant("value"))) + assertEquals(2.0, expr.evaluate(mapOf("value" to 2.0), 0.0)) + assertEquals(2.0, expr.evaluate(mapOf("value" to -2.0), 0.0)) + assertEquals(null, expr.evaluate(mapOf("value" to null), 0.0)) + assertEquals(null, expr.evaluate(mapOf("value" to "not a number"), 0.0)) + } + + @Test + fun `test evaluate trigonometric expressions`() { + val sinExpr = Expr.Sin(Expr.Get(Expr.Constant("value"))) + val cosExpr = Expr.Cos(Expr.Get(Expr.Constant("value"))) + val tanExpr = Expr.Tan(Expr.Get(Expr.Constant("value"))) + val asinExpr = Expr.Asin(Expr.Get(Expr.Constant("value"))) + val acosExpr = Expr.Acos(Expr.Get(Expr.Constant("value"))) + val atanExpr = Expr.Atan(Expr.Get(Expr.Constant("value"))) + + assertEquals(0.0, sinExpr.evaluate(mapOf("value" to 0.0), 0.0)) + assertEquals(1.0, cosExpr.evaluate(mapOf("value" to 0.0), 0.0)) + assertEquals(0.0, tanExpr.evaluate(mapOf("value" to 0.0), 0.0)) + assertEquals(0.0, asinExpr.evaluate(mapOf("value" to 0.0), 0.0)) + assertEquals(PI / 2, acosExpr.evaluate(mapOf("value" to 0.0), 0.0)) + assertEquals(0.0, atanExpr.evaluate(mapOf("value" to 0.0), 0.0)) + + assertEquals(null, sinExpr.evaluate(mapOf("value" to null), 0.0)) + assertEquals(null, sinExpr.evaluate(mapOf("value" to "not a number"), 0.0)) + } + + @Test + fun `test evaluate rounding expressions`() { + val ceilExpr = Expr.Ceil(Expr.Get(Expr.Constant("value"))) + val floorExpr = Expr.Floor(Expr.Get(Expr.Constant("value"))) + val roundExpr = Expr.Round(Expr.Get(Expr.Constant("value"))) + + assertEquals(3.0, ceilExpr.evaluate(mapOf("value" to 2.3), 0.0)) + assertEquals(2.0, floorExpr.evaluate(mapOf("value" to 2.3), 0.0)) + assertEquals(2.0, roundExpr.evaluate(mapOf("value" to 2.3), 0.0)) + + assertEquals(null, ceilExpr.evaluate(mapOf("value" to null), 0.0)) + assertEquals(null, ceilExpr.evaluate(mapOf("value" to "not a number"), 0.0)) + } + + @Test + fun `test evaluate logarithmic expressions`() { + val lnExpr = Expr.Ln(Expr.Get(Expr.Constant("value"))) + val log10Expr = Expr.Log10(Expr.Get(Expr.Constant("value"))) + + assertEquals(0.0, lnExpr.evaluate(mapOf("value" to 1.0), 0.0)) + assertEquals(0.0, log10Expr.evaluate(mapOf("value" to 1.0), 0.0)) + + assertEquals(null, lnExpr.evaluate(mapOf("value" to 0.0), 0.0)) + assertEquals(null, lnExpr.evaluate(mapOf("value" to -1.0), 0.0)) + assertEquals(null, lnExpr.evaluate(mapOf("value" to null), 0.0)) + assertEquals(null, lnExpr.evaluate(mapOf("value" to "not a number"), 0.0)) + } + + @Test + fun `test evaluate min max expressions`() { + val minExpr = Expr.Min(Expr.Get(Expr.Constant("value1")), Expr.Get(Expr.Constant("value2"))) + val maxExpr = Expr.Max(Expr.Get(Expr.Constant("value1")), Expr.Get(Expr.Constant("value2"))) + + assertEquals(2.0, minExpr.evaluate(mapOf("value1" to 2.0, "value2" to 3.0), 0.0)) + assertEquals(3.0, maxExpr.evaluate(mapOf("value1" to 2.0, "value2" to 3.0), 0.0)) + + assertEquals(null, minExpr.evaluate(mapOf("value1" to null, "value2" to 3.0), 0.0)) + assertEquals(null, minExpr.evaluate(mapOf("value1" to "not a number", "value2" to 3.0), 0.0)) + } + + @Test + fun `test evaluate sqrt expression`() { + val expr = Expr.Sqrt(Expr.Get(Expr.Constant("value"))) + assertEquals(2.0, expr.evaluate(mapOf("value" to 4.0), 0.0)) + assertEquals(null, expr.evaluate(mapOf("value" to -1.0), 0.0)) + assertEquals(null, expr.evaluate(mapOf("value" to null), 0.0)) + assertEquals(null, expr.evaluate(mapOf("value" to "not a number"), 0.0)) + } + + + + @Test + fun `test deserialize hsl color value`() { + val jsonStr = """"hsl(75, 51%, 85%)"""" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(ColorSerializer), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Value) + assertEquals(Color.hsl(hue = 75F, saturation = 0.51F, lightness = 0.85F), deserialized.value) + } + + @Test + fun `test deserialize expression color value`() { + val jsonStr = """[ + "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" + ]""" + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(ColorSerializer), jsonStr) + assertTrue(deserialized is ExpressionOrValue.Expression) + deserialized.expr as Expr.Match + val firstBranch = deserialized.expr.branches.first() + val matchExpr = deserialized.expr + assertEquals(Color(0xFFD6C7FF), (firstBranch.second as Expr.Constant).value) + assertEquals(Color(0xFFEAB38F), (matchExpr.elseExpr as Expr.Constant).value) + } + + @Test + fun `test deserialize legacy expression 001`() { + val input = """{ + "stops": [ + [ + 0, + 2 + ], + [ + 6, + 6 + ], + [ + 14, + 9 + ], + [ + 22, + 18 + ] + ] + }""" + + val deserialized = json.decodeFromString(ExpressionOrValueSerializer(Double.serializer()), input) + + assertEquals(6.0, deserialized.process(null, zoom = 6.0)) + + } + + @Test + fun toNumberTest() { + val input = JsonArray( + listOf( + JsonPrimitive("to-number"), + JsonArray( + listOf( + JsonPrimitive("get"), + JsonPrimitive("rank") + ) + ) + ) + ) + + val expr = json.decodeFromJsonElement(ExpressionOrValueSerializer(Double.serializer()), input) + + + val result = expr.process(mapOf("rank" to "123"), zoom = null) + assertEquals(123.0, result) + } +} \ No newline at end of file diff --git a/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/NormalizeLegacyExpressionTest.kt b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/NormalizeLegacyExpressionTest.kt new file mode 100644 index 00000000..76b67028 --- /dev/null +++ b/library/src/commonTest/kotlin/ovh/plrapps/mapcompose/vector/spec/style/utils/NormalizeLegacyExpressionTest.kt @@ -0,0 +1,166 @@ +package ovh.plrapps.mapcompose.vector.spec.style.utils + +import kotlinx.serialization.ExperimentalSerializationApi +import ovh.plrapps.mapcompose.vector.data.json +import kotlinx.serialization.json.* +import kotlin.test.Test +import kotlin.test.assertEquals + +class NormalizeLegacyExpressionTest { + + @Test + fun `test stops-only shorthand interpolation`() { + val input = JsonObject(mapOf( + "stops" to JsonArray(listOf( + JsonArray(listOf(JsonPrimitive(14), JsonArray(listOf(JsonPrimitive(1), JsonPrimitive(0.5))))), + JsonArray(listOf(JsonPrimitive(18), JsonArray(listOf(JsonPrimitive(1), JsonPrimitive(0.25))))) + )) + )) + + val expected = JsonArray(listOf( + JsonPrimitive("interpolate"), + JsonArray(listOf(JsonPrimitive("linear"))), + JsonPrimitive("zoom"), + JsonPrimitive(14), + JsonArray(listOf(JsonPrimitive(1), JsonPrimitive(0.5))), + JsonPrimitive(18), + JsonArray(listOf(JsonPrimitive(1), JsonPrimitive(0.25))) + )) + + val result = normalizeLegacyExpression(input) + assertEquals(expected, result) + } + + @Test + fun `test identity expression`() { + val input = JsonObject(mapOf( + "type" to JsonPrimitive("identity"), + "property" to JsonPrimitive("color") + )) + + val expected = JsonArray(listOf( + JsonPrimitive("get"), + JsonPrimitive("color") + )) + + val result = normalizeLegacyExpression(input) + assertEquals(expected, result) + } + + @Test + fun `test exponential interpolation`() { + val input = JsonObject(mapOf( + "type" to JsonPrimitive("exponential"), + "property" to JsonPrimitive("population"), + "base" to JsonPrimitive(1.5), + "stops" to JsonArray(listOf( + JsonArray(listOf(JsonPrimitive(0), JsonPrimitive(0))), + JsonArray(listOf(JsonPrimitive(1000000), JsonPrimitive(1))) + )) + )) + + val expected = JsonArray(listOf( + JsonPrimitive("interpolate"), + JsonArray(listOf( + JsonPrimitive("exponential"), + JsonPrimitive(1.5) + )), + JsonArray(listOf( + JsonPrimitive("get"), + JsonPrimitive("population") + )), + JsonPrimitive(0), + JsonPrimitive(0), + JsonPrimitive(1000000), + JsonPrimitive(1) + )) + + val result = normalizeLegacyExpression(input) + assertEquals(expected, result) + } + + @Test + fun `test interval expression`() { + val input = JsonObject(mapOf( + "type" to JsonPrimitive("interval"), + "property" to JsonPrimitive("population"), + "default" to JsonPrimitive("gray"), + "stops" to JsonArray(listOf( + JsonArray(listOf(JsonPrimitive(0), JsonPrimitive("red"))), + JsonArray(listOf(JsonPrimitive(1000000), JsonPrimitive("yellow"))), + JsonArray(listOf(JsonPrimitive(5000000), JsonPrimitive("green"))) + )) + )) + + val expected = buildJsonArray { + add("step") + add(buildJsonArray { + add("get") + add("population") + }) + add("gray") + add(0) + add("red") + add(1_000_000) + add("yellow") + add(5_000_000) + add("green") + } + + val result = normalizeLegacyExpression(input) + assertEquals(expected, result) + } + @Test + + fun `test zoom-based stops-only shorthand`() { + val input = """{"stops":[[14,[1,0.5]],[18,[1,0.25]]]}""" + val element = json.decodeFromString(input) + val expected = buildJsonArray { + add("interpolate") + add(buildJsonArray { add("linear") }) + add("zoom") + add(14) + add(buildJsonArray { + add(1) + add(0.5) + }) + add(18) + add(buildJsonArray { + add(1) + add(0.25) + }) + } + + val result = normalizeLegacyExpression(element) + assertEquals(expected, result) + } + + @OptIn(ExperimentalSerializationApi::class) + @Test + fun `test categorical expression`() { + val input = JsonObject(mapOf( + "type" to JsonPrimitive("categorical"), + "property" to JsonPrimitive("type"), + "stops" to JsonArray(listOf( + JsonArray(listOf(JsonPrimitive("residential"), JsonPrimitive("red"))), + JsonArray(listOf(JsonPrimitive("commercial"), JsonPrimitive("blue"))) + )) + )) + + val expected = JsonArray(listOf( + JsonPrimitive("match"), + JsonArray(listOf( + JsonPrimitive("get"), + JsonPrimitive("type") + )), + JsonPrimitive("residential"), + JsonPrimitive("red"), + JsonPrimitive("commercial"), + JsonPrimitive("blue"), + JsonPrimitive(null) + )) + + val result = normalizeLegacyExpression(input) + assertEquals(expected, result) + } +} \ No newline at end of file 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/settings.gradle.kts b/settings.gradle.kts index e78b75f5..c100856d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,3 +20,4 @@ dependencyResolutionManagement { include(":demo:composeApp") include(":library") +include(":demo:maplibreDebugApp")