Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit f5c8e84d authored by Fynn Godau's avatar Fynn Godau
Browse files

Custom styles implementation

parent 00323fd2
Loading
Loading
Loading
Loading
+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"

@@ -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)

@@ -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"))
                                }
                            }
                        }
                    }
                }
@@ -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?,
@@ -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())
    }
}