Loading play-services-maps-core-mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/Styles.kt +255 −21 Original line number Diff line number Diff line package org.microg.gms.maps.mapbox import android.graphics.Color import android.util.Log import androidx.annotation.ColorInt import androidx.annotation.FloatRange import androidx.core.graphics.ColorUtils import com.google.android.gms.maps.model.MapStyleOptions import com.google.gson.Gson import com.google.gson.JsonSyntaxException import com.google.gson.annotations.SerializedName import com.mapbox.mapboxsdk.maps.Style import org.json.JSONArray import org.json.JSONObject import org.microg.gms.maps.MapsConstants import org.microg.gms.maps.mapbox.utils.MapContext import java.lang.NumberFormatException import kotlin.math.pow const val TAG = "GmsMapStyles" Loading @@ -16,14 +23,16 @@ const val TAG = "GmsMapStyles" fun getStyle(context: MapContext, storedMapType: Int, styleOptions: MapStyleOptions?): Style.Builder { // TODO: Serve map style resources locally val styleJson = JSONObject(context.assets.open( val styleJson = JSONObject( context.assets.open( when (storedMapType) { MapsConstants.MAP_TYPE_SATELLITE, MapsConstants.MAP_TYPE_HYBRID -> "style-microg-satellite.json" MapsConstants.MAP_TYPE_TERRAIN -> "style-mapbox-outdoors-v12.json" //MAP_TYPE_NONE, MAP_TYPE_NORMAL, else -> "style-microg-normal.json" } ).bufferedReader().readText()) ).bufferedReader().readText() ) styleOptions?.apply(styleJson) Loading @@ -35,19 +44,61 @@ fun MapStyleOptions.apply(style: JSONObject) { Gson().fromJson(json, Array<StyleOperation>::class.java).let { styleOperations -> val layerArray = style.getJSONArray("layers") for (i in 0 until layerArray.length()) { // Apply operations in order operations@ for (operation in styleOperations.map { // Fill in default values for optional fields NonNullStyleOperation(it.featureType ?: "all", it.elementType ?: "all", it.stylers ?: emptyArray()) }) { // Reverse direction allows removing hidden layers layers@ for (i in layerArray.length() - 1 downTo 0) { // Test if layer has required fields (metadata and paint) val layer = layerArray.getJSONObject(i) if (layer.has("metadata") && layer.getJSONObject("metadata") if (layer.has("paint") && layer.has("metadata") && layer.getJSONObject("metadata") .let { it.has("microg:gms-type-feature") && it.has("microg:gms-type-element") } ) { val featureType = layer.getJSONObject("metadata").getString("microg:gms-type-feature") val elementType = layer.getJSONObject("metadata").getString("microg:gms-type-element") val layerFeatureType = layer.getJSONObject("metadata").getString("microg:gms-type-feature") val layerElementType = layer.getJSONObject("metadata").getString("microg:gms-type-element") for (operation in styleOperations) { if (operation.featureType?.startsWith(featureType) != false && // Todo: "all" here as well? (operation.elementType?.startsWith(elementType) != false || operation.elementType == "all") if (operation.featureType.startsWith("administrative") && operation.elementType.startsWith("geometry") ) { operation.stylers?.forEach { styler -> styler.apply(layer) } /* Per docs: * `administrative` selects all administrative areas. Styling affects only * the labels of administrative areas, not the geographical borders or fill. */ continue@operations } // Layer metadata always has the most concrete category; operation applies to all subcategories as well. if ((layerFeatureType.startsWith(operation.featureType) || operation.featureType == "all") && (layerElementType.startsWith(operation.elementType) || operation.elementType == "all") ) { // Here, operation should be applied to this layer. Log.v(TAG, "applying ${Gson().toJson(operation)} to $layer") // Interpretation of visibility "simplified": hide labels, display geometry if ( // A styler sets the layer to be invisible operation.stylers.any { it.visibility == "off" } || // A styler sets the layer to simplified and we are working with a label (layerElementType.startsWith("labels") && operation.stylers.any { it.visibility == "simplified" }) ) { layerArray.remove(i) Log.v(TAG, "removing $layer") continue@layers } operation.stylers.forEach { styler -> when (operation.elementType) { "labels.text.fill" -> styler.applyTextFill(layer.getJSONObject("paint")) "labels.text.outline" -> styler.applyTextOutline(layer.getJSONObject("paint")) else -> styler.traverseApply(layer.getJSONObject("paint")) } } } } } Loading @@ -62,6 +113,8 @@ fun MapStyleOptions.apply(style: JSONObject) { class StyleOperation(val featureType: String?, val elementType: String?, val stylers: Array<Styler>?) class NonNullStyleOperation(val featureType: String, val elementType: String, val stylers: Array<Styler>) class Styler( val hue: String?, @FloatRange(from = -100.0, to = 100.0) val saturation: Float?, Loading @@ -73,4 +126,185 @@ class Styler( val weight: Int? ) fun Styler.apply(layer: JSONObject) { TODO() } No newline at end of file /** * Returns true if string is likely to contain a color. */ fun String.isColor() = startsWith("hsl(") || startsWith("#") || startsWith("rgba(") /** * Can parse colors in the format '#rrggbb', '#aarrggbb', 'hsl(h, s, l)', and 'rgba(r, g, b, a)' * Returns 0 and prints to log if an invalid color is provided. */ @ColorInt fun String.parseColor(): Int { if (startsWith("#") && length in listOf(7, 9)) { return Color.parseColor(this) } else if (startsWith("hsl(")) { val hsvArray = replace("hsl(", "").replace(")", "").split(", ") if (hsvArray.size != 3) { Log.w(TAG, "Invalid color `$this`") return 0 } return try { Color.HSVToColor( floatArrayOf( hsvArray[0].toFloat(), hsvArray[1].parseFloat(), hsvArray[2].parseFloat() ) ) } catch (e: NumberFormatException) { Log.w(TAG, "Invalid color `$this`") 0 } } else if (startsWith("rgba(")) { return com.mapbox.mapboxsdk.utils.ColorUtils.rgbaToColor(this) } Log.w(TAG, "Invalid color `$this`") return 0 } /** * Formats color int in such a format that it MapLibre's rendering engine understands it. */ fun Int.colorToString() = com.mapbox.mapboxsdk.utils.ColorUtils.colorToRgbaString(this) /** * Can parse string values that contain '%'. */ fun String.parseFloat(): Float { return if (contains("%")) { replace("%", "").toFloat() } else { toFloat() } } /** * Applies operation specified by styler to the provided color int, and returns * a new, corresponding color int. */ @ColorInt fun Styler.applyColorChanges(color: Int): Int { // There may only be one operation per styler per docs. hue?.let { hue -> // Extract hue from input color val hslResult = FloatArray(3) ColorUtils.colorToHSL(hue.parseColor(), hslResult) val hueDegree = hslResult[0] // Apply hue to layer color ColorUtils.colorToHSL(color, hslResult) hslResult[0] = hueDegree return ColorUtils.HSLToColor(hslResult) } lightness?.let { lightness -> // Apply lightness to layer color val hsl = FloatArray(3) ColorUtils.colorToHSL(color, hsl) hsl[2] = if (lightness < 0) { // Increase darkness. Percentage amount = relative reduction of is-lightness. (lightness / 100 + 1) * hsl[2] } else { // Increase brightness. Percentage amount = relative reduction of difference between is-lightness and 1.0. hsl[2] + (lightness / 100) * (1 - hsl[2]) } return ColorUtils.HSLToColor(hsl) } saturation?.let { saturation -> // Apply saturation to layer color val hsl = FloatArray(3) ColorUtils.colorToHSL(color, hsl) hsl[1] = if (saturation < 0) { // Reduce intensity. Percentage amount = relative reduction of is-saturation. (saturation / 100 + 1) * hsl[1] } else { // Increase intensity. Percentage amount = relative reduction of difference between is-saturation and 1.0. hsl[1] + (saturation / 100) * (1 - hsl[1]) } return ColorUtils.HSLToColor(hsl) } gamma?.let { gamma -> // Apply gamma to layer color val hsl = FloatArray(3) ColorUtils.colorToHSL(color, hsl) hsl[2] = hsl[2].toDouble().pow(gamma.toDouble()).toFloat() return ColorUtils.HSLToColor(hsl) } if (invertLightness == true) { // Invert layer color's lightness val hsl = FloatArray(3) ColorUtils.colorToHSL(color, hsl) hsl[2] = 1 - hsl[2] return ColorUtils.HSLToColor(hsl) } this.color?.let { return it.parseColor() } Log.w(TAG, "No applicable operation") return color } /** * Traverse JSON object and replace any color strings according to styler */ fun Styler.traverseApply(json: JSONObject) { // Traverse layer and replace any color strings json.keys().forEach { key -> json.get(key).let { when (it) { is JSONObject -> traverseApply(it) is JSONArray -> traverseApply(it) is String -> if (it.isColor()) { json.put(key, applyColorChanges(it.parseColor()).colorToString()) } } } } } /** * Traverse array and replace any color strings according to styler */ fun Styler.traverseApply(array: JSONArray) { for (i in 0 until array.length()) { array.get(i).let { when (it) { is JSONObject -> traverseApply(it) is JSONArray -> traverseApply(it) is String -> if (it.isColor()) { array.put(i, applyColorChanges(it.parseColor()).colorToString()) } } } } } fun Styler.applyTextFill(paint: JSONObject) { if (paint.has("text-color")) when (val textColor = paint.get("text-color")) { is JSONObject -> traverseApply(textColor) is JSONArray -> traverseApply(textColor) is String -> paint.put("text-color", applyColorChanges(textColor.parseColor()).colorToString()) } } fun Styler.applyTextOutline(paint: JSONObject) { if (paint.has("text-halo-color")) when (val textOutline = paint.get("text-halo-color")) { is JSONObject -> traverseApply(textOutline) is JSONArray -> traverseApply(textOutline) is String -> paint.put("text-halo-color", applyColorChanges(textOutline.parseColor()).colorToString()) } } Loading
play-services-maps-core-mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/Styles.kt +255 −21 Original line number Diff line number Diff line package org.microg.gms.maps.mapbox import android.graphics.Color import android.util.Log import androidx.annotation.ColorInt import androidx.annotation.FloatRange import androidx.core.graphics.ColorUtils import com.google.android.gms.maps.model.MapStyleOptions import com.google.gson.Gson import com.google.gson.JsonSyntaxException import com.google.gson.annotations.SerializedName import com.mapbox.mapboxsdk.maps.Style import org.json.JSONArray import org.json.JSONObject import org.microg.gms.maps.MapsConstants import org.microg.gms.maps.mapbox.utils.MapContext import java.lang.NumberFormatException import kotlin.math.pow const val TAG = "GmsMapStyles" Loading @@ -16,14 +23,16 @@ const val TAG = "GmsMapStyles" fun getStyle(context: MapContext, storedMapType: Int, styleOptions: MapStyleOptions?): Style.Builder { // TODO: Serve map style resources locally val styleJson = JSONObject(context.assets.open( val styleJson = JSONObject( context.assets.open( when (storedMapType) { MapsConstants.MAP_TYPE_SATELLITE, MapsConstants.MAP_TYPE_HYBRID -> "style-microg-satellite.json" MapsConstants.MAP_TYPE_TERRAIN -> "style-mapbox-outdoors-v12.json" //MAP_TYPE_NONE, MAP_TYPE_NORMAL, else -> "style-microg-normal.json" } ).bufferedReader().readText()) ).bufferedReader().readText() ) styleOptions?.apply(styleJson) Loading @@ -35,19 +44,61 @@ fun MapStyleOptions.apply(style: JSONObject) { Gson().fromJson(json, Array<StyleOperation>::class.java).let { styleOperations -> val layerArray = style.getJSONArray("layers") for (i in 0 until layerArray.length()) { // Apply operations in order operations@ for (operation in styleOperations.map { // Fill in default values for optional fields NonNullStyleOperation(it.featureType ?: "all", it.elementType ?: "all", it.stylers ?: emptyArray()) }) { // Reverse direction allows removing hidden layers layers@ for (i in layerArray.length() - 1 downTo 0) { // Test if layer has required fields (metadata and paint) val layer = layerArray.getJSONObject(i) if (layer.has("metadata") && layer.getJSONObject("metadata") if (layer.has("paint") && layer.has("metadata") && layer.getJSONObject("metadata") .let { it.has("microg:gms-type-feature") && it.has("microg:gms-type-element") } ) { val featureType = layer.getJSONObject("metadata").getString("microg:gms-type-feature") val elementType = layer.getJSONObject("metadata").getString("microg:gms-type-element") val layerFeatureType = layer.getJSONObject("metadata").getString("microg:gms-type-feature") val layerElementType = layer.getJSONObject("metadata").getString("microg:gms-type-element") for (operation in styleOperations) { if (operation.featureType?.startsWith(featureType) != false && // Todo: "all" here as well? (operation.elementType?.startsWith(elementType) != false || operation.elementType == "all") if (operation.featureType.startsWith("administrative") && operation.elementType.startsWith("geometry") ) { operation.stylers?.forEach { styler -> styler.apply(layer) } /* Per docs: * `administrative` selects all administrative areas. Styling affects only * the labels of administrative areas, not the geographical borders or fill. */ continue@operations } // Layer metadata always has the most concrete category; operation applies to all subcategories as well. if ((layerFeatureType.startsWith(operation.featureType) || operation.featureType == "all") && (layerElementType.startsWith(operation.elementType) || operation.elementType == "all") ) { // Here, operation should be applied to this layer. Log.v(TAG, "applying ${Gson().toJson(operation)} to $layer") // Interpretation of visibility "simplified": hide labels, display geometry if ( // A styler sets the layer to be invisible operation.stylers.any { it.visibility == "off" } || // A styler sets the layer to simplified and we are working with a label (layerElementType.startsWith("labels") && operation.stylers.any { it.visibility == "simplified" }) ) { layerArray.remove(i) Log.v(TAG, "removing $layer") continue@layers } operation.stylers.forEach { styler -> when (operation.elementType) { "labels.text.fill" -> styler.applyTextFill(layer.getJSONObject("paint")) "labels.text.outline" -> styler.applyTextOutline(layer.getJSONObject("paint")) else -> styler.traverseApply(layer.getJSONObject("paint")) } } } } } Loading @@ -62,6 +113,8 @@ fun MapStyleOptions.apply(style: JSONObject) { class StyleOperation(val featureType: String?, val elementType: String?, val stylers: Array<Styler>?) class NonNullStyleOperation(val featureType: String, val elementType: String, val stylers: Array<Styler>) class Styler( val hue: String?, @FloatRange(from = -100.0, to = 100.0) val saturation: Float?, Loading @@ -73,4 +126,185 @@ class Styler( val weight: Int? ) fun Styler.apply(layer: JSONObject) { TODO() } No newline at end of file /** * Returns true if string is likely to contain a color. */ fun String.isColor() = startsWith("hsl(") || startsWith("#") || startsWith("rgba(") /** * Can parse colors in the format '#rrggbb', '#aarrggbb', 'hsl(h, s, l)', and 'rgba(r, g, b, a)' * Returns 0 and prints to log if an invalid color is provided. */ @ColorInt fun String.parseColor(): Int { if (startsWith("#") && length in listOf(7, 9)) { return Color.parseColor(this) } else if (startsWith("hsl(")) { val hsvArray = replace("hsl(", "").replace(")", "").split(", ") if (hsvArray.size != 3) { Log.w(TAG, "Invalid color `$this`") return 0 } return try { Color.HSVToColor( floatArrayOf( hsvArray[0].toFloat(), hsvArray[1].parseFloat(), hsvArray[2].parseFloat() ) ) } catch (e: NumberFormatException) { Log.w(TAG, "Invalid color `$this`") 0 } } else if (startsWith("rgba(")) { return com.mapbox.mapboxsdk.utils.ColorUtils.rgbaToColor(this) } Log.w(TAG, "Invalid color `$this`") return 0 } /** * Formats color int in such a format that it MapLibre's rendering engine understands it. */ fun Int.colorToString() = com.mapbox.mapboxsdk.utils.ColorUtils.colorToRgbaString(this) /** * Can parse string values that contain '%'. */ fun String.parseFloat(): Float { return if (contains("%")) { replace("%", "").toFloat() } else { toFloat() } } /** * Applies operation specified by styler to the provided color int, and returns * a new, corresponding color int. */ @ColorInt fun Styler.applyColorChanges(color: Int): Int { // There may only be one operation per styler per docs. hue?.let { hue -> // Extract hue from input color val hslResult = FloatArray(3) ColorUtils.colorToHSL(hue.parseColor(), hslResult) val hueDegree = hslResult[0] // Apply hue to layer color ColorUtils.colorToHSL(color, hslResult) hslResult[0] = hueDegree return ColorUtils.HSLToColor(hslResult) } lightness?.let { lightness -> // Apply lightness to layer color val hsl = FloatArray(3) ColorUtils.colorToHSL(color, hsl) hsl[2] = if (lightness < 0) { // Increase darkness. Percentage amount = relative reduction of is-lightness. (lightness / 100 + 1) * hsl[2] } else { // Increase brightness. Percentage amount = relative reduction of difference between is-lightness and 1.0. hsl[2] + (lightness / 100) * (1 - hsl[2]) } return ColorUtils.HSLToColor(hsl) } saturation?.let { saturation -> // Apply saturation to layer color val hsl = FloatArray(3) ColorUtils.colorToHSL(color, hsl) hsl[1] = if (saturation < 0) { // Reduce intensity. Percentage amount = relative reduction of is-saturation. (saturation / 100 + 1) * hsl[1] } else { // Increase intensity. Percentage amount = relative reduction of difference between is-saturation and 1.0. hsl[1] + (saturation / 100) * (1 - hsl[1]) } return ColorUtils.HSLToColor(hsl) } gamma?.let { gamma -> // Apply gamma to layer color val hsl = FloatArray(3) ColorUtils.colorToHSL(color, hsl) hsl[2] = hsl[2].toDouble().pow(gamma.toDouble()).toFloat() return ColorUtils.HSLToColor(hsl) } if (invertLightness == true) { // Invert layer color's lightness val hsl = FloatArray(3) ColorUtils.colorToHSL(color, hsl) hsl[2] = 1 - hsl[2] return ColorUtils.HSLToColor(hsl) } this.color?.let { return it.parseColor() } Log.w(TAG, "No applicable operation") return color } /** * Traverse JSON object and replace any color strings according to styler */ fun Styler.traverseApply(json: JSONObject) { // Traverse layer and replace any color strings json.keys().forEach { key -> json.get(key).let { when (it) { is JSONObject -> traverseApply(it) is JSONArray -> traverseApply(it) is String -> if (it.isColor()) { json.put(key, applyColorChanges(it.parseColor()).colorToString()) } } } } } /** * Traverse array and replace any color strings according to styler */ fun Styler.traverseApply(array: JSONArray) { for (i in 0 until array.length()) { array.get(i).let { when (it) { is JSONObject -> traverseApply(it) is JSONArray -> traverseApply(it) is String -> if (it.isColor()) { array.put(i, applyColorChanges(it.parseColor()).colorToString()) } } } } } fun Styler.applyTextFill(paint: JSONObject) { if (paint.has("text-color")) when (val textColor = paint.get("text-color")) { is JSONObject -> traverseApply(textColor) is JSONArray -> traverseApply(textColor) is String -> paint.put("text-color", applyColorChanges(textColor.parseColor()).colorToString()) } } fun Styler.applyTextOutline(paint: JSONObject) { if (paint.has("text-halo-color")) when (val textOutline = paint.get("text-halo-color")) { is JSONObject -> traverseApply(textOutline) is JSONArray -> traverseApply(textOutline) is String -> paint.put("text-halo-color", applyColorChanges(textOutline.parseColor()).colorToString()) } }