Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4223f89
build successful
westnordost Jun 17, 2025
e483ade
remove duplicate layer
westnordost Jun 17, 2025
af7868f
add missing green color
westnordost Jun 17, 2025
3302cb9
fix utility function
westnordost Jun 17, 2025
348a72b
widen zoom range
westnordost Jun 17, 2025
d78042c
fade-in for barriers
westnordost Jun 17, 2025
cd87644
add test screen
westnordost Jun 17, 2025
5f33aa2
fix localized names
westnordost Jun 17, 2025
f3185f0
implemented some layers
westnordost Jun 18, 2025
d403a97
remove some resources (not possible to have them in multiplatform yet…
westnordost Jun 18, 2025
b82fb75
add GeometryMarkersLayers
westnordost Jun 19, 2025
0f9dbcd
remove todo
westnordost Jun 19, 2025
78b81d3
prepare for multiplatform access to local glyphs
westnordost Jun 20, 2025
8457579
more stuff
westnordost Jun 20, 2025
09bb626
styleable overlay layers
westnordost Jun 23, 2025
ede5856
restore two files
westnordost Jun 23, 2025
96fc2f5
bla
westnordost Jun 24, 2025
eedcce9
remove invalid properties
westnordost Jun 24, 2025
769c5bd
add comment about missing feature
westnordost Jun 24, 2025
ecbbc0d
small stuff...
westnordost Jun 24, 2025
818f219
small stuff...
westnordost Jun 24, 2025
09a5c08
put colors into Color. object
westnordost Jun 24, 2025
a23436b
Merge branch 'master' into maplibre-compose
westnordost Jul 21, 2025
43ed854
update maplibre-compose to v0.10.1
westnordost Jul 21, 2025
b82e4a3
Merge branch 'master' into maplibre-compose
westnordost Sep 7, 2025
9b0e16d
upgrade to 0.11.1
westnordost Sep 7, 2025
0215258
Merge branch 'master' into maplibre-compose
westnordost Mar 3, 2026
2216d3a
no debug
westnordost Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ kotlin {

// UI widgets

// Map
implementation("org.maplibre.compose:maplibre-compose:0.11.1")

// non-lazy grid
implementation("com.cheonjaeung.compose.grid:grid:2.5.2")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ class StyleableOverlayMapComponent(
if (overlayStyle.height != null && overlayStyle.color != Invisible) {
p.addProperty("height", overlayStyle.height)
if (overlayStyle.minHeight != null) {
p.addProperty("min-height", overlayStyle.minHeight.coerceAtMost(overlayStyle.minHeight))
p.addProperty("min-height", overlayStyle.minHeight.coerceAtMost(overlayStyle.height))
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package de.westnordost.streetcomplete.screens.main.map2

import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import org.maplibre.compose.expressions.ast.Expression
import org.maplibre.compose.expressions.dsl.Feature
import org.maplibre.compose.expressions.dsl.all
import org.maplibre.compose.expressions.dsl.any
import org.maplibre.compose.expressions.dsl.coalesce
import org.maplibre.compose.expressions.dsl.condition
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.contains
import org.maplibre.compose.expressions.dsl.convertToBoolean
import org.maplibre.compose.expressions.dsl.convertToNumber
import org.maplibre.compose.expressions.dsl.convertToString
import org.maplibre.compose.expressions.dsl.div
import org.maplibre.compose.expressions.dsl.dp
import org.maplibre.compose.expressions.dsl.eq
import org.maplibre.compose.expressions.dsl.exponential
import org.maplibre.compose.expressions.dsl.interpolate
import org.maplibre.compose.expressions.dsl.neq
import org.maplibre.compose.expressions.dsl.plus
import org.maplibre.compose.expressions.dsl.switch
import org.maplibre.compose.expressions.dsl.times
import org.maplibre.compose.expressions.dsl.zoom
import org.maplibre.compose.expressions.value.GeometryType
import org.maplibre.compose.expressions.value.NumberValue
import org.maplibre.compose.expressions.value.StringValue
import kotlin.math.PI
import kotlin.math.cos

fun fadeInAtZoom(start: Float, range: Float = 1f, endOpacity: Float = 1f) =
byZoom(start to 0f, start+range to endOpacity)

fun fadeOutAtZoom(start: Float, range: Float = 1f, startOpacity: Float = 1f) =
byZoom(start to startOpacity, start+range to 0f)

@JvmName("byZoomFloat")
fun byZoom(vararg stops: Pair<Number, Float>) =
interpolate(exponential(2f), zoom(), *stops.map { it.first to const(it.second) }.toTypedArray())

@JvmName("byZoomDp")
fun byZoom(vararg stops: Pair<Number, Dp>) =
interpolate(exponential(2f), zoom(), *stops.map { it.first to const(it.second) }.toTypedArray())

@JvmName("byZoomTextUnit")
fun byZoom(vararg stops: Pair<Number, TextUnit>) =
interpolate(exponential(2f), zoom(), *stops.map { it.first to const(it.second) }.toTypedArray())

/** Returns whether this feature has the given [key]-[value] pair */
fun Feature.has(key: String, value: String) =
get(key).convertToString() eq const(value)

/** Returns whether this feature has the given [key]-[value] pair */
fun Feature.has(key: String, value: Int) =
get(key).convertToNumber() eq const(value)

/** Returns whether this feature has the given [key]-[value] pair */
fun Feature.has(key: String, value: Boolean) =
get(key).convertToBoolean() eq const(value)

/** Returns whether this feature has a [key]-value pair of which the value is in of the given
* [values] */
fun Feature.hasAny(key: String, values: List<String>) =
const(values).contains(get(key))

fun Feature.isPoint() =
geometryType() eq const(GeometryType.Point)

fun Feature.isLines() =
any(
geometryType() eq const(GeometryType.LineString),
geometryType() eq const(GeometryType.MultiLineString)
)

fun Feature.isArea() =
any(
geometryType() eq const(GeometryType.Polygon),
geometryType() eq const(GeometryType.MultiPolygon)
)

/** Get an expression that resolves to the localized name.
* If the localized name in the user's [language] is the same as the primary name, then only this
* name is displayed. Otherwise, the primary name is displayed, then the localized name below */
fun Feature.localizedName(
languages: List<String>,
nameKey: String,
localizedNameKey: (String) -> String,
extraNameKeys: List<String>
): Expression<StringValue> {
val localizedNameKeys = languages.map(localizedNameKey) + extraNameKeys
val getLocalizedName = coalesce(*localizedNameKeys.map { get(it) }.toTypedArray())
val getName = get(nameKey).cast<StringValue>()
return switch(
// localized name set and different as main name -> show both
condition(
test = all(getLocalizedName.convertToBoolean(), getName neq getLocalizedName.cast()),
output = getName + const("\n") + getLocalizedName.cast()
),
// otherwise just show the name
fallback = getName
)
}

fun inMeters(
width: Expression<NumberValue<Number>>,
latitude: Double = 30.0
): Expression<NumberValue<Dp>> {
// the more north you go, the smaller of an area each mercator tile actually covers
// the additional factor of 1.20 comes from a simple measuring test with a ruler on a
// smartphone screen done at approx. latitude = 0 and latitude = 70, i.e. without it, lines are
// drawn at both latitudes approximately 20% too large ¯\_(ツ)_/¯
val sizeFactor = (cos(PI * latitude / 180) * 1.2).toFloat()
return interpolate(
exponential(2f), zoom(),
8 to width / const(256) / const(sizeFactor),
24 to width * const(256) / const(sizeFactor)
).dp
}

fun inMeters(
width: Float,
latitude: Double = 30.0
): Expression<NumberValue<Dp>> {
val sizeFactor = (cos(PI * latitude / 180) * 1.2).toFloat()
return interpolate(
exponential(2f), zoom(),
8 to const(width) / const(256) / const(sizeFactor),
24 to const(width) * const(256) / const(sizeFactor)
).dp
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package de.westnordost.streetcomplete.screens.main.map2

import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry
import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry
import de.westnordost.streetcomplete.data.osm.geometry.ElementPolygonsGeometry
import de.westnordost.streetcomplete.data.osm.geometry.ElementPolylinesGeometry
import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
import de.westnordost.streetcomplete.util.math.isInPolygon
import de.westnordost.streetcomplete.util.math.isRingDefinedClockwise
import de.westnordost.streetcomplete.util.math.measuredArea
import io.github.dellisd.spatialk.geojson.Geometry
import io.github.dellisd.spatialk.geojson.LineString
import io.github.dellisd.spatialk.geojson.MultiLineString
import io.github.dellisd.spatialk.geojson.MultiPolygon
import io.github.dellisd.spatialk.geojson.Point
import io.github.dellisd.spatialk.geojson.Polygon
import io.github.dellisd.spatialk.geojson.Position

typealias GeoJsonBoundingBox = io.github.dellisd.spatialk.geojson.BoundingBox

fun BoundingBox.toGeoJsonBoundingBox(): GeoJsonBoundingBox =
GeoJsonBoundingBox(
west = min.longitude,
south = min.latitude,
east = max.longitude,
north = max.latitude
)

fun GeoJsonBoundingBox.toBoundingBox(): BoundingBox =
BoundingBox(
minLatitude = southwest.latitude,
minLongitude = southwest.longitude,
maxLatitude = northeast.latitude,
maxLongitude = northeast.longitude
)

fun ElementGeometry.toGeometry(): Geometry = when (this) {
is ElementPointGeometry -> toGeometry()
is ElementPolylinesGeometry -> toGeometry()
is ElementPolygonsGeometry -> toGeometry()
}

fun ElementPointGeometry.toGeometry(): Point =
Point(center.toPosition())

fun ElementPolylinesGeometry.toGeometry(): Geometry =
if (polylines.size == 1) {
LineString(polylines.single().map { it.toPosition() })
} else {
MultiLineString(polylines.map { polyline -> polyline.map { it.toPosition() } })
}

fun ElementPolygonsGeometry.toGeometry(): Geometry {
val outerRings = mutableListOf<List<LatLon>>()
val innerRings = mutableListOf<List<LatLon>>()
if (polygons.size == 1) {
outerRings.add(polygons.first())
} else {
polygons.forEach {
if (it.isRingDefinedClockwise()) innerRings.add(it) else outerRings.add(it)
}
}

if (outerRings.size == 1) {
return Polygon(
(outerRings + innerRings).map { ring -> ring.map { it.toPosition() } }
)
}

// outerRings must be sorted size ascending to correctly handle outer rings within holes
// of larger polygons.
outerRings.sortBy { it.measuredArea() }

// we need to allocate the holes to the different outer polygons
val groupedRings = outerRings.map { outerRing ->
val rings = mutableListOf<List<Position>>()
rings.add(outerRing.map { it.toPosition() })
for (innerRing in innerRings.toList()) {
if (innerRing[0].isInPolygon(outerRing)) {
innerRings.remove(innerRing)
rings.add(innerRing.map { it.toPosition() })
}
}
rings
}
return MultiPolygon(groupedRings)
}

fun LatLon.toGeometry(): Point =
Point(Position(longitude = longitude, latitude = latitude))

fun LatLon.toPosition(): Position =
Position(longitude = longitude, latitude = latitude)

fun Position.toLatLon(): LatLon =
LatLon(latitude = latitude, longitude = longitude)
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package de.westnordost.streetcomplete.screens.main.map2

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.intl.Locale
import de.westnordost.streetcomplete.resources.Res
import de.westnordost.streetcomplete.screens.main.map2.layers.CurrentLocationLayers
import de.westnordost.streetcomplete.screens.main.map2.layers.DownloadedAreaLayer
import de.westnordost.streetcomplete.screens.main.map2.layers.FocusedGeometryLayers
import de.westnordost.streetcomplete.screens.main.map2.layers.GeometryMarkersLayers
import de.westnordost.streetcomplete.screens.main.map2.layers.PinsLayers
import de.westnordost.streetcomplete.screens.main.map2.layers.SelectedPinsLayer
import de.westnordost.streetcomplete.screens.main.map2.layers.StyleableOverlayLabelLayer
import de.westnordost.streetcomplete.screens.main.map2.layers.StyleableOverlayLayers
import de.westnordost.streetcomplete.screens.main.map2.layers.StyleableOverlaySideLayer
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.style.BaseStyle
import org.maplibre.compose.style.StyleState
import org.maplibre.compose.style.rememberStyleState

/**
* A plain MapLibre Map with StreetComplete theme and localized names
* */
@Composable
fun Map(
modifier: Modifier = Modifier,
cameraState: CameraState = rememberCameraState(),
styleState: StyleState = rememberStyleState(),
) {
MaplibreMap(
modifier = modifier,
baseStyle = BaseStyle.Json(BASE_STYLE),
zoomRange = 0f..22f,
cameraState = cameraState,
styleState = styleState,
options = MapOptions(
ornamentOptions = OrnamentOptions.AllDisabled
)
) {
val languages = listOf(Locale.current.language)
val colors = if (isSystemInDarkTheme()) MapColors.Night else MapColors.Light

MapStyle(
colors = colors,
languages = languages,
belowRoadsContent = {
// left-and-right lines should be rendered behind the actual road
StyleableOverlaySideLayer(styleableOverlaySource, isBridge = false)
},
belowRoadsOnBridgeContent = {
// left-and-right lines should be rendered behind the actual bridge road
StyleableOverlaySideLayer(styleableOverlaySource, isBridge = true)
},
belowLabelsContent = {
// labels should be on top of other layers
DownloadedAreaLayer(tiles)
StyleableOverlayLayers(styleableOverlaySource, onClickOverlay)
TracksLayers()
},
aboveLabelsContent = {
// these are always on top of everything else (including labels)
StyleableOverlayLabelLayer(styleableOverlaySource, colors.text, colors.textOutline, onClickOverlay)
GeometryMarkersLayers(markers)
FocusedGeometryLayers(geometry)
CurrentLocationLayers(location, rotation)
PinsLayers(pins, onClickPin, onClickCluster)
SelectedPinsLayer(iconPainter, pinPositions)
}
)
}
}

// need to refer to the local (font) resources platform-independently
private val BASE_STYLE = """
{
"version": 8,
"name": "Empty",
"metadata": {},
"sources": {},
"glyphs": "${
Res.getUri("files/glyphs/Roboto Regular/0-255.pbf")
.replace("Roboto Regular", "{fontstack}")
.replace("0-255", "{range}")
}",
"layers": []
}
""".trimIndent()
Loading