From ef3ff80197f80e69bb6e6178b236efbf4f942602 Mon Sep 17 00:00:00 2001 From: Tobias Zwick Date: Wed, 8 Jan 2025 05:20:43 +0100 Subject: [PATCH] Reimplement Scalebar (#232) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Sargun Vohra --- .../sargunv/maplibrecompose/demoapp/app.kt | 6 +- .../maplibrecompose/demoapp/docs/Material3.kt | 8 +- .../maplibrecompose/material3/util.android.kt | 20 + .../composeResources/values-es/strings.xml | 3 - .../composeResources/values/strings.xml | 1 + .../maplibrecompose/material3/DrawScope.kt | 81 ++++ .../controls/DisappearingScaleBar.kt | 82 ++++ .../material3/controls/ScaleBar.kt | 372 ++++++++---------- .../material3/controls/ScaleBarMeasure.kt | 92 +++++ .../material3/controls/TextWithHalo.kt | 93 ----- .../sargunv/maplibrecompose/material3/util.kt | 91 +++++ .../maplibrecompose/material3/util.desktop.kt | 7 + .../maplibrecompose/material3/util.ios.kt | 19 + .../maplibrecompose/material3/util.js.kt | 6 + 14 files changed, 570 insertions(+), 311 deletions(-) create mode 100644 lib/maplibre-compose-material3/src/androidMain/kotlin/dev/sargunv/maplibrecompose/material3/util.android.kt delete mode 100644 lib/maplibre-compose-material3/src/commonMain/composeResources/values-es/strings.xml create mode 100644 lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/DrawScope.kt create mode 100644 lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/DisappearingScaleBar.kt create mode 100644 lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/ScaleBarMeasure.kt delete mode 100644 lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/TextWithHalo.kt create mode 100644 lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/util.kt create mode 100644 lib/maplibre-compose-material3/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/material3/util.desktop.kt create mode 100644 lib/maplibre-compose-material3/src/iosMain/kotlin/dev/sargunv/maplibrecompose/material3/util.ios.kt create mode 100644 lib/maplibre-compose-material3/src/jsMain/kotlin/dev/sargunv/maplibrecompose/material3/util.js.kt diff --git a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/app.kt b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/app.kt index 9103bc72..f8adf973 100644 --- a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/app.kt +++ b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/app.kt @@ -156,7 +156,11 @@ fun DemoMapControls( ) { if (Platform.supportsBlending) { Box(modifier = modifier.fillMaxSize().padding(8.dp)) { - DisappearingScaleBar(cameraState, modifier = Modifier.align(Alignment.TopStart)) + DisappearingScaleBar( + metersPerDp = cameraState.metersPerDpAtTarget, + zoom = cameraState.position.zoom, + modifier = Modifier.align(Alignment.TopStart), + ) DisappearingCompassButton( cameraState, modifier = Modifier.align(Alignment.TopEnd), diff --git a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/docs/Material3.kt b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/docs/Material3.kt index 38c42379..da51d0cc 100644 --- a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/docs/Material3.kt +++ b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/docs/Material3.kt @@ -33,7 +33,7 @@ fun Material3() { ) Box(modifier = Modifier.fillMaxSize().padding(8.dp)) { - ScaleBar(cameraState, modifier = Modifier.align(Alignment.TopStart)) + ScaleBar(cameraState.metersPerDpAtTarget, modifier = Modifier.align(Alignment.TopStart)) CompassButton(cameraState, modifier = Modifier.align(Alignment.TopEnd)) AttributionButton(styleState, modifier = Modifier.align(Alignment.BottomEnd)) } @@ -49,7 +49,11 @@ fun Material3() { ) Box(modifier = Modifier.fillMaxSize().padding(8.dp)) { - DisappearingScaleBar(cameraState, modifier = Modifier.align(Alignment.TopStart)) // (1)! + DisappearingScaleBar( + metersPerDp = cameraState.metersPerDpAtTarget, + zoom = cameraState.position.zoom, + modifier = Modifier.align(Alignment.TopStart), + ) // (1)! DisappearingCompassButton(cameraState, modifier = Modifier.align(Alignment.TopEnd)) // (2)! AttributionButton(styleState, modifier = Modifier.align(Alignment.BottomEnd)) } diff --git a/lib/maplibre-compose-material3/src/androidMain/kotlin/dev/sargunv/maplibrecompose/material3/util.android.kt b/lib/maplibre-compose-material3/src/androidMain/kotlin/dev/sargunv/maplibrecompose/material3/util.android.kt new file mode 100644 index 00000000..4bd9068c --- /dev/null +++ b/lib/maplibre-compose-material3/src/androidMain/kotlin/dev/sargunv/maplibrecompose/material3/util.android.kt @@ -0,0 +1,20 @@ +package dev.sargunv.maplibrecompose.material3 + +import android.icu.util.LocaleData +import android.icu.util.ULocale +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import dev.sargunv.maplibrecompose.material3.controls.ScaleBarMeasure + +@Composable +internal actual fun systemDefaultPrimaryMeasure(): ScaleBarMeasure? { + if (android.os.Build.VERSION.SDK_INT < 28) return null + val locales = LocalContext.current.resources.configuration.locales + if (locales.isEmpty) return null + return when (LocaleData.getMeasurementSystem(ULocale.forLocale(locales[0]))) { + LocaleData.MeasurementSystem.SI -> ScaleBarMeasure.Metric + LocaleData.MeasurementSystem.US -> ScaleBarMeasure.FeetAndMiles + LocaleData.MeasurementSystem.UK -> ScaleBarMeasure.YardsAndMiles + else -> null + } +} diff --git a/lib/maplibre-compose-material3/src/commonMain/composeResources/values-es/strings.xml b/lib/maplibre-compose-material3/src/commonMain/composeResources/values-es/strings.xml deleted file mode 100644 index d1a0406d..00000000 --- a/lib/maplibre-compose-material3/src/commonMain/composeResources/values-es/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - pies - diff --git a/lib/maplibre-compose-material3/src/commonMain/composeResources/values/strings.xml b/lib/maplibre-compose-material3/src/commonMain/composeResources/values/strings.xml index 38e889b1..751216c8 100644 --- a/lib/maplibre-compose-material3/src/commonMain/composeResources/values/strings.xml +++ b/lib/maplibre-compose-material3/src/commonMain/composeResources/values/strings.xml @@ -5,5 +5,6 @@ m km ft + yd mi diff --git a/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/DrawScope.kt b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/DrawScope.kt new file mode 100644 index 00000000..a0fdbc3f --- /dev/null +++ b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/DrawScope.kt @@ -0,0 +1,81 @@ +package dev.sargunv.maplibrecompose.material3 + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultBlendMode +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.drawText + +/** Draw several lines. Each offset in [path] is relative to the previous. */ +internal fun DrawScope.drawPath( + color: Color, + path: List, + strokeWidth: Float = Stroke.HairlineWidth, + cap: StrokeCap = Stroke.DefaultCap, + pathEffect: PathEffect? = null, + alpha: Float = 1.0f, + colorFilter: ColorFilter? = null, + blendMode: BlendMode = DefaultBlendMode, +) { + val it = path.iterator() + if (!it.hasNext()) return + var start = it.next() + while (it.hasNext()) { + val end = start + it.next() + drawLine( + color = color, + start = start, + end = end, + strokeWidth = strokeWidth, + cap = cap, + pathEffect = pathEffect, + alpha = alpha, + colorFilter = colorFilter, + blendMode = blendMode, + ) + start = end + } +} + +/** Draw several paths with halo. All halos of all [paths] are behind all strokes. */ +internal fun DrawScope.drawPathsWithHalo( + color: Color, + haloColor: Color, + paths: List>, + strokeWidth: Float = Stroke.HairlineWidth, + haloWidth: Float = Stroke.HairlineWidth, + cap: StrokeCap = Stroke.DefaultCap, +) { + for (path in paths) { + drawPath(color = haloColor, path = path, strokeWidth = strokeWidth + haloWidth * 2, cap = cap) + } + for (path in paths) { + drawPath(color = color, path = path, strokeWidth = strokeWidth, cap = cap) + } +} + +internal fun DrawScope.drawTextWithHalo( + textLayoutResult: TextLayoutResult, + topLeft: Offset = Offset.Zero, + color: Color = Color.Unspecified, + haloColor: Color = Color.Unspecified, + haloWidth: Float = 0f, +) { + // * 2 because the stroke is painted half outside and half inside of the text shape + val stroke = Stroke(width = haloWidth * 2, cap = StrokeCap.Round, join = StrokeJoin.Round) + drawText( + textLayoutResult = textLayoutResult, + color = haloColor, + topLeft = topLeft, + drawStyle = stroke, + ) + drawText(textLayoutResult = textLayoutResult, color = color, topLeft = topLeft, drawStyle = Fill) +} diff --git a/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/DisappearingScaleBar.kt b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/DisappearingScaleBar.kt new file mode 100644 index 00000000..cbf231ed --- /dev/null +++ b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/DisappearingScaleBar.kt @@ -0,0 +1,82 @@ +package dev.sargunv.maplibrecompose.material3.controls + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import dev.sargunv.maplibrecompose.material3.defaultScaleBarMeasures +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay + +/** + * An animated scale bar that appears when the [zoom] level of the map changes, and then disappears + * after [visibilityDuration]. This composable wraps [ScaleBar] with visibility animations. + * + * @param metersPerDp how many meters are displayed in one device independent pixel (dp), i.e. the + * scale. See + * [CameraState.metersPerDpAtTarget][dev.sargunv.maplibrecompose.compose.CameraState.metersPerDpAtTarget] + * @param zoom zoom level of the map + * @param modifier the [Modifier] to be applied to this layout node + * @param measures which measures to show on the scale bar. If `null`, measures will be selected + * based on the system settings or otherwise the user's locale. + * @param haloColor halo for better visibility when displayed on top of the map + * @param color scale bar and text color. + * @param textStyle the text style. The text size is the deciding factor how large the scale bar is + * is displayed. + * @param alignment horizontal alignment of the scale bar and text + * @param visibilityDuration how long it should be visible after the zoom changed + * @param enterTransition EnterTransition(s) used for the appearing animation + * @param exitTransition ExitTransition(s) used for the disappearing animation + */ +@Composable +public fun DisappearingScaleBar( + metersPerDp: Double, + zoom: Double, + modifier: Modifier = Modifier, + measures: ScaleBarMeasures = defaultScaleBarMeasures(), + haloColor: Color = MaterialTheme.colorScheme.surface, + color: Color = contentColorFor(haloColor), + textStyle: TextStyle = MaterialTheme.typography.labelMedium, + alignment: Alignment.Horizontal = Alignment.Start, + visibilityDuration: Duration = 3.seconds, + enterTransition: EnterTransition = fadeIn(), + exitTransition: ExitTransition = fadeOut(), +) { + val visible = remember { MutableTransitionState(true) } + + LaunchedEffect(zoom) { + // Show ScaleBar + visible.targetState = true + delay(visibilityDuration) + // Hide ScaleBar after timeout period + visible.targetState = false + } + + AnimatedVisibility( + visibleState = visible, + modifier = modifier, + enter = enterTransition, + exit = exitTransition, + ) { + ScaleBar( + metersPerDp = metersPerDp, + measures = measures, + haloColor = haloColor, + color = color, + textStyle = textStyle, + alignment = alignment, + ) + } +} diff --git a/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/ScaleBar.kt b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/ScaleBar.kt index f7bd8e45..b75fd6fc 100644 --- a/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/ScaleBar.kt +++ b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/ScaleBar.kt @@ -1,253 +1,201 @@ package dev.sargunv.maplibrecompose.material3.controls -// Based on the scale bar from Google Maps Compose. - -// Copyright 2022 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import dev.sargunv.maplibrecompose.compose.CameraState -import dev.sargunv.maplibrecompose.material3.generated.Res -import dev.sargunv.maplibrecompose.material3.generated.feet_symbol -import dev.sargunv.maplibrecompose.material3.generated.kilometers_symbol -import dev.sargunv.maplibrecompose.material3.generated.meters_symbol -import dev.sargunv.maplibrecompose.material3.generated.miles_symbol -import kotlin.math.roundToInt -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.delay -import org.jetbrains.compose.resources.stringResource - -public data class ScaleBarColors( - val textColor: Color, - val lineColor: Color, - val shadowColor: Color, +import androidx.compose.ui.unit.toSize +import dev.sargunv.maplibrecompose.material3.defaultScaleBarMeasures +import dev.sargunv.maplibrecompose.material3.drawPathsWithHalo +import dev.sargunv.maplibrecompose.material3.drawTextWithHalo + +/** Which measures to show on the scale bar. */ +public data class ScaleBarMeasures( + val primary: ScaleBarMeasure, + val secondary: ScaleBarMeasure? = null, ) -public object ScaleBarDefaults { - public val width: Dp = 65.dp - public val height: Dp = 50.dp - - @Composable - public fun colors(): ScaleBarColors = - ScaleBarColors( - textColor = MaterialTheme.colorScheme.onSurface, - lineColor = MaterialTheme.colorScheme.onSurface, - shadowColor = MaterialTheme.colorScheme.surface, - ) -} - /** - * A scale bar composable that shows the current scale of the map in feet and meters when zoomed in - * to the map, changing to miles and kilometers, respectively, when zooming out. + * A scale bar composable that shows the current scale of the map in feet, meters or feet and meters + * when zoomed in to the map, changing to miles and kilometers, respectively, when zooming out. + * + * @param metersPerDp how many meters are displayed in one device independent pixel (dp), i.e. the + * scale. See + * [CameraState.metersPerDpAtTarget][dev.sargunv.maplibrecompose.compose.CameraState.metersPerDpAtTarget] + * @param modifier the [Modifier] to be applied to this layout node + * @param measures which measures to show on the scale bar. If `null`, measures will be selected + * based on the system settings or otherwise the user's locale. + * @param haloColor halo for better visibility when displayed on top of the map + * @param color scale bar and text color. + * @param textStyle the text style. The text size is the deciding factor how large the scale bar is + * is displayed. + * @param alignment horizontal alignment of the scale bar and text */ @Composable public fun ScaleBar( - cameraState: CameraState, + metersPerDp: Double, modifier: Modifier = Modifier, - width: Dp = ScaleBarDefaults.width, - height: Dp = ScaleBarDefaults.height, - colors: ScaleBarColors = ScaleBarDefaults.colors(), + measures: ScaleBarMeasures = defaultScaleBarMeasures(), + haloColor: Color = MaterialTheme.colorScheme.surface, + color: Color = contentColorFor(haloColor), + textStyle: TextStyle = MaterialTheme.typography.labelSmall, + alignment: Alignment.Horizontal = Alignment.Start, ) { - Box(modifier = modifier.size(width = width, height = height)) { - var horizontalLineWidthMeters by remember { mutableDoubleStateOf(0.0) } - - Canvas( - modifier = Modifier.fillMaxSize(), - onDraw = { - horizontalLineWidthMeters = cameraState.metersPerDpAtTarget * size.width.toDp().value - - val start = - when (layoutDirection) { - LayoutDirection.Ltr -> 0f - LayoutDirection.Rtl -> size.width - } - val midHeight = size.height / 2 - val oneThirdHeight = size.height / 3 - val twoThirdsHeight = size.height * 2 / 3 - val strokeWidth = 2f * density - val shadowStrokeWidth = strokeWidth + 2f * density - - // Middle horizontal line shadow (drawn under main lines) - drawLine( - color = colors.shadowColor, - start = Offset(0f, midHeight), - end = Offset(size.width, midHeight), - strokeWidth = shadowStrokeWidth, - cap = StrokeCap.Round, - ) - // Top vertical line shadow (drawn under main lines) - drawLine( - color = colors.shadowColor, - start = Offset(start, oneThirdHeight), - end = Offset(start, midHeight), - strokeWidth = shadowStrokeWidth, - cap = StrokeCap.Round, + val textMeasurer = rememberTextMeasurer() + // longest possible text + val maxTextSizePx = + remember(textMeasurer, textStyle) { textMeasurer.measure("5000 km", textStyle).size } + val maxTextSize = with(LocalDensity.current) { maxTextSizePx.toSize().toDpSize() } + + // bar stroke width + val strokeWidth = 2.dp + val haloStrokeWidth = 1.dp + // padding of text to bar stroke + val textHorizontalPadding = 4.dp + val textVerticalPadding = 0.dp + + // multiplied by 2.5 because the next stop can be the x2.5 of a previous stop (e.g. 2km -> 5km), + // so the bar can end at approx 1/2.5th of the total width. We want to avoid that the bar + // intersects with the text, i.e. is drawn behind the text + val totalMaxWidth = maxTextSize.width * 2.5f + (textHorizontalPadding + strokeWidth) * 2f + + val fullStrokeWidth = haloStrokeWidth * 2 + strokeWidth + + val textCount = if (measures.secondary != null) 2 else 1 + val totalHeight = (maxTextSize.height + textVerticalPadding) * textCount + fullStrokeWidth + + BoxWithConstraints(modifier.size(totalMaxWidth, totalHeight)) { + // scale bar start/end should not overlap horizontally with canvas bounds + val maxBarLength = maxWidth - fullStrokeWidth + + val params1 = scaleBarParameters(measures.primary, metersPerDp, maxBarLength) + val params2 = measures.secondary?.let { scaleBarParameters(it, metersPerDp, maxBarLength) } + + Canvas(modifier.fillMaxSize()) { + val fullStrokeWidthPx = fullStrokeWidth.toPx() + val textHeightPx = maxTextSizePx.height + val textHorizontalPaddingPx = textHorizontalPadding.toPx() + val textVerticalPaddingPx = textVerticalPadding.toPx() + + // bar ends should go to the vertical center of the text + val barEndsHeightPx = textHeightPx / 2f + textVerticalPadding.toPx() + fullStrokeWidthPx / 2f + + var y = 0f + val paths = ArrayList>(2) + val texts = ArrayList>(2) + + if (true) { // just want a scope here + val offsetX = + alignment.align( + size = params1.barLength.toPx().toInt(), + space = (size.width - fullStrokeWidthPx).toInt(), + layoutDirection = layoutDirection, + ) + paths.add( + listOf( + Offset(offsetX + fullStrokeWidthPx / 2f, 0f + textHeightPx / 2f), + Offset(0f, barEndsHeightPx), + Offset(params1.barLength.toPx(), 0f), + Offset(0f, -barEndsHeightPx), + ) ) - // Bottom vertical line shadow (drawn under main lines) - drawLine( - color = colors.shadowColor, - start = Offset(start, midHeight), - end = Offset(start, twoThirdsHeight), - strokeWidth = shadowStrokeWidth, - cap = StrokeCap.Round, + texts.add( + Pair( + Offset(textHorizontalPaddingPx + fullStrokeWidthPx, 0f), + textMeasurer.measure(params1.text, textStyle), + ) ) - // Middle horizontal line - drawLine( - color = colors.lineColor, - start = Offset(0f, midHeight), - end = Offset(size.width, midHeight), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - // Top vertical line - drawLine( - color = colors.lineColor, - start = Offset(start, oneThirdHeight), - end = Offset(start, midHeight), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - // Bottom vertical line - drawLine( - color = colors.lineColor, - start = Offset(start, midHeight), - end = Offset(start, twoThirdsHeight), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - }, - ) - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.SpaceAround, - ) { - var metricUnits = stringResource(Res.string.meters_symbol) - var metricDistance = horizontalLineWidthMeters - if (horizontalLineWidthMeters > METERS_IN_KILOMETER) { - // Switch from meters to kilometers as unit - metricUnits = stringResource(Res.string.kilometers_symbol) - metricDistance /= METERS_IN_KILOMETER.toInt() + y += textHeightPx + textVerticalPaddingPx } - var imperialUnits = stringResource(Res.string.feet_symbol) - var imperialDistance = horizontalLineWidthMeters.toFeet() - if (imperialDistance > FEET_IN_MILE) { - // Switch from ft to miles as unit - imperialUnits = stringResource(Res.string.miles_symbol) - imperialDistance = imperialDistance.toMiles() + if (params2 != null) { + val offsetX = + alignment.align( + size = params2.barLength.toPx().toInt(), + space = (size.width - fullStrokeWidthPx).toInt(), + layoutDirection = layoutDirection, + ) + paths.add( + listOf( + Offset(offsetX + fullStrokeWidthPx / 2f, y + fullStrokeWidthPx / 2f + barEndsHeightPx), + Offset(0f, -barEndsHeightPx), + Offset(params2.barLength.toPx(), 0f), + Offset(0f, +barEndsHeightPx), + ) + ) + texts.add( + Pair( + Offset( + textHorizontalPaddingPx + fullStrokeWidthPx, + y + textVerticalPaddingPx + fullStrokeWidthPx, + ), + textMeasurer.measure(params2.text, textStyle), + ) + ) } - TextWithHalo( - text = "${imperialDistance.roundToInt()} $imperialUnits", - haloColor = colors.shadowColor, - color = colors.textColor, - style = MaterialTheme.typography.labelMedium, - ) - TextWithHalo( - text = "${metricDistance.roundToInt()} $metricUnits", - haloColor = colors.shadowColor, - color = colors.textColor, - style = MaterialTheme.typography.labelMedium, + drawPathsWithHalo( + color = color, + haloColor = haloColor, + paths = paths, + strokeWidth = strokeWidth.toPx(), + haloWidth = haloStrokeWidth.toPx(), + cap = StrokeCap.Round, ) + + for ((offset, textLayoutResult) in texts) { + val offsetX = + alignment.align( + size = textLayoutResult.size.width, + space = (size.width - 2 * offset.x).toInt(), + layoutDirection = layoutDirection, + ) + offset.x + drawTextWithHalo( + textLayoutResult = textLayoutResult, + topLeft = Offset(offsetX, offset.y), + color = color, + haloColor = haloColor, + haloWidth = haloStrokeWidth.toPx(), + ) + } } } } -/** - * An animated scale bar that appears when the zoom level of the map changes, and then disappears - * after [visibilityDuration]. This composable wraps [ScaleBar] with visibility animations. - */ -@Composable -public fun DisappearingScaleBar( - cameraState: CameraState, - modifier: Modifier = Modifier, - width: Dp = ScaleBarDefaults.width, - height: Dp = ScaleBarDefaults.height, - colors: ScaleBarColors = ScaleBarDefaults.colors(), - visibilityDuration: Duration = 3.seconds, - enterTransition: EnterTransition = fadeIn(), - exitTransition: ExitTransition = fadeOut(), -) { - val visible = remember { MutableTransitionState(true) } - - LaunchedEffect(key1 = cameraState.position.zoom) { - // Show ScaleBar - visible.targetState = true - delay(visibilityDuration) - // Hide ScaleBar after timeout period - visible.targetState = false - } - - AnimatedVisibility( - visibleState = visible, - modifier = modifier, - enter = enterTransition, - exit = exitTransition, - ) { - ScaleBar(width = width, height = height, cameraState = cameraState, colors = colors) - } -} +private data class ScaleBarParams(val barLength: Dp, val text: String) -/** - * Converts [this] value in meters to the corresponding value in feet - * - * @return [this] meters value converted to feet - */ -private fun Double.toFeet(): Double { - return this * CENTIMETERS_IN_METER / CENTIMETERS_IN_INCH / INCHES_IN_FOOT +@Composable +private fun scaleBarParameters( + measure: ScaleBarMeasure, + metersPerDp: Double, + maxBarLength: Dp, +): ScaleBarParams { + val max = maxBarLength.value * metersPerDp / measure.unitInMeters + val stop = findStop(max, measure.stops) + return ScaleBarParams((stop * measure.unitInMeters / metersPerDp).dp, measure.getText(stop)) } /** - * Converts [this] value in feet to the corresponding value in miles - * - * @return [this] feet value converted to miles + * find the largest stop in the list of stops (sorted in ascending order) that is below or equal + * [max]. */ -private fun Double.toMiles(): Double { - return this / FEET_IN_MILE +private fun findStop(max: Double, stops: List): Double { + val i = stops.binarySearch { it.compareTo(max) } + return if (i >= 0) stops[i] else stops[(-i - 2).coerceAtLeast(0)] } - -private const val CENTIMETERS_IN_METER: Double = 100.0 -private const val METERS_IN_KILOMETER: Double = 1000.0 -private const val CENTIMETERS_IN_INCH: Double = 2.54 -private const val INCHES_IN_FOOT: Double = 12.0 -private const val FEET_IN_MILE: Double = 5280.0 diff --git a/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/ScaleBarMeasure.kt b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/ScaleBarMeasure.kt new file mode 100644 index 00000000..098c2d67 --- /dev/null +++ b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/ScaleBarMeasure.kt @@ -0,0 +1,92 @@ +package dev.sargunv.maplibrecompose.material3.controls + +import androidx.compose.runtime.Composable +import dev.sargunv.maplibrecompose.material3.generated.Res +import dev.sargunv.maplibrecompose.material3.generated.feet_symbol +import dev.sargunv.maplibrecompose.material3.generated.kilometers_symbol +import dev.sargunv.maplibrecompose.material3.generated.meters_symbol +import dev.sargunv.maplibrecompose.material3.generated.miles_symbol +import dev.sargunv.maplibrecompose.material3.generated.yards_symbol +import kotlin.math.pow +import org.jetbrains.compose.resources.stringResource + +/** A measure to show in the scale bar */ +public interface ScaleBarMeasure { + /** one unit of this measure in meters */ + public val unitInMeters: Double + + /** List of stops, sorted ascending, at which the scalebar should show */ + public val stops: List + + @Composable public fun getText(stop: Double): String + + /** A measure of meters and kilometers */ + public data object Metric : ScaleBarMeasure { + override val unitInMeters: Double = 1.0 + + override val stops: List = buildStops(mantissas = listOf(1, 2, 5), exponents = -1..7) + + @Composable + override fun getText(stop: Double): String = + if (stop >= 1000) { + (stop / 1000).formatForDisplay(stringResource(Res.string.kilometers_symbol)) + } else { + stop.formatForDisplay(stringResource(Res.string.meters_symbol)) + } + } + + /** A measure of international feet and miles */ + public data object FeetAndMiles : ScaleBarMeasure { + + private const val FEET_IN_MILE: Int = 5280 + + override val unitInMeters: Double = 0.3048 + + override val stops: List = + listOf( + buildStops(mantissas = listOf(1, 2, 5), exponents = -1..3).dropLast(1), + buildStops(mantissas = listOf(1, 2, 5), exponents = 0..4).map { it * FEET_IN_MILE }, + ) + .flatten() + + @Composable + override fun getText(stop: Double): String = + if (stop >= FEET_IN_MILE) { + (stop / FEET_IN_MILE).formatForDisplay(stringResource(Res.string.miles_symbol)) + } else { + stop.formatForDisplay(stringResource(Res.string.feet_symbol)) + } + } + + /** A measure of international yard and miles */ + public data object YardsAndMiles : ScaleBarMeasure { + + private const val YARDS_IN_MILE: Int = 1760 + + override val unitInMeters: Double = 0.9144 + + override val stops: List = + listOf( + buildStops(mantissas = listOf(1, 2, 5), exponents = -1..3).dropLast(2), + buildStops(mantissas = listOf(1, 2, 5), exponents = 0..4).map { it * YARDS_IN_MILE }, + ) + .flatten() + + @Composable + override fun getText(stop: Double): String = + if (stop >= YARDS_IN_MILE) { + (stop / YARDS_IN_MILE).formatForDisplay(stringResource(Res.string.miles_symbol)) + } else { + stop.formatForDisplay(stringResource(Res.string.yards_symbol)) + } + } +} + +/** format a number with a unit symbol, not showing the decimal point if it's an integer */ +private fun Double.formatForDisplay(symbol: String) = + if (this.toInt().toDouble() == this) "${this.toInt()} $symbol" else "${this} $symbol" + +/** build a list of stops by multiplying mantissas by 10^exponents, like scientific notation */ +private fun buildStops(mantissas: List, exponents: IntRange) = buildList { + for (e in exponents) for (m in mantissas) add(m * 10.0.pow(e)) +} diff --git a/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/TextWithHalo.kt b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/TextWithHalo.kt deleted file mode 100644 index 2b595382..00000000 --- a/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/TextWithHalo.kt +++ /dev/null @@ -1,93 +0,0 @@ -package dev.sargunv.maplibrecompose.material3.controls - -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp - -/** A text that has a halo. */ -@Composable -internal fun TextWithHalo( - text: String, - modifier: Modifier = Modifier, - haloColor: Color = MaterialTheme.colorScheme.surface, - haloWidth: Dp = 1.dp, - color: Color = contentColorFor(haloColor), - fontSize: TextUnit = TextUnit.Unspecified, - fontStyle: FontStyle? = null, - fontWeight: FontWeight? = null, - fontFamily: FontFamily? = null, - letterSpacing: TextUnit = TextUnit.Unspecified, - textDecoration: TextDecoration? = null, - textAlign: TextAlign? = null, - lineHeight: TextUnit = TextUnit.Unspecified, - overflow: TextOverflow = TextOverflow.Clip, - softWrap: Boolean = true, - maxLines: Int = Int.MAX_VALUE, - minLines: Int = 1, - onTextLayout: ((TextLayoutResult) -> Unit)? = null, - style: TextStyle = LocalTextStyle.current, -) { - // * 2 because the stroke is painted half outside and half inside of the text shape - val strokeWidth = with(LocalDensity.current) { haloWidth.toPx() } - val stroke = Stroke(strokeWidth * 2, cap = StrokeCap.Round, join = StrokeJoin.Round) - Box { - Text( - text = text, - modifier = modifier, - color = haloColor, - fontSize = fontSize, - fontStyle = fontStyle, - fontWeight = fontWeight, - fontFamily = fontFamily, - letterSpacing = letterSpacing, - textDecoration = textDecoration, - textAlign = textAlign, - lineHeight = lineHeight, - overflow = overflow, - softWrap = softWrap, - maxLines = maxLines, - minLines = minLines, - onTextLayout = onTextLayout, - style = style.copy(drawStyle = stroke), - ) - Text( - text = text, - modifier = modifier, - color = color, - fontSize = fontSize, - fontStyle = fontStyle, - fontWeight = fontWeight, - fontFamily = fontFamily, - letterSpacing = letterSpacing, - textDecoration = textDecoration, - textAlign = textAlign, - lineHeight = lineHeight, - overflow = overflow, - softWrap = softWrap, - maxLines = maxLines, - minLines = minLines, - onTextLayout = null, - style = style, - ) - } -} diff --git a/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/util.kt b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/util.kt new file mode 100644 index 00000000..f41838cd --- /dev/null +++ b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/util.kt @@ -0,0 +1,91 @@ +package dev.sargunv.maplibrecompose.material3 + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.intl.Locale +import dev.sargunv.maplibrecompose.material3.controls.ScaleBarMeasure +import dev.sargunv.maplibrecompose.material3.controls.ScaleBarMeasure.FeetAndMiles +import dev.sargunv.maplibrecompose.material3.controls.ScaleBarMeasure.Metric +import dev.sargunv.maplibrecompose.material3.controls.ScaleBarMeasure.YardsAndMiles +import dev.sargunv.maplibrecompose.material3.controls.ScaleBarMeasures + +/** use system locale APIs for the primary scale bar measure */ +@Composable internal expect fun systemDefaultPrimaryMeasure(): ScaleBarMeasure? + +/** if the system APIs don't provide a primary measure, fall back to our hardcoded lists */ +internal fun fallbackDefaultPrimaryMeasure(region: String?): ScaleBarMeasure = + when (region) { + in regionsUsingFeetAndMiles -> FeetAndMiles + in regionsUsingYardsAndMiles -> YardsAndMiles + else -> Metric + } + +/** countries using non-metric units will see both systems by default */ +internal fun defaultSecondaryMeasure(primary: ScaleBarMeasure, region: String?): ScaleBarMeasure? = + when (primary) { + FeetAndMiles -> Metric + YardsAndMiles -> Metric + Metric -> + when (region) { + in regionsUsingFeetAndMiles -> FeetAndMiles + in regionsUsingYardsAndMiles -> YardsAndMiles + else -> null + } + else -> null // should never happen because the primary is always one of the above + } + +internal val regionsUsingFeetAndMiles = + listOf( + // United states and its unincorporated territories + "US", + "AS", + "GU", + "MP", + "PR", + "VI", + // former United states territories / Compact of Free Association + "FM", + "MH", + "PW", + // Liberia + "LR", + ) + +internal val regionsUsingYardsAndMiles = + listOf( + // United kingdom with its overseas territories and crown dependencies + "GB", + "AI", + "BM", + "FK", + "GG", + "GI", + "GS", + "IM", + "IO", + "JE", + "KY", + "MS", + "PN", + "SH", + "TC", + "VG", + // former British overseas territories / colonies + "BS", + "BZ", + "GD", + "KN", + "VC", + // Myanmar + "MM", + ) + +/** + * default scale bar measures to use, depending on the user's locale (or system preferences, if + * available) + */ +@Composable +internal fun defaultScaleBarMeasures(): ScaleBarMeasures { + val region = Locale.current.region + val primary = systemDefaultPrimaryMeasure() ?: fallbackDefaultPrimaryMeasure(region) + return ScaleBarMeasures(primary = primary, secondary = defaultSecondaryMeasure(primary, region)) +} diff --git a/lib/maplibre-compose-material3/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/material3/util.desktop.kt b/lib/maplibre-compose-material3/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/material3/util.desktop.kt new file mode 100644 index 00000000..3bb3a4b0 --- /dev/null +++ b/lib/maplibre-compose-material3/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/material3/util.desktop.kt @@ -0,0 +1,7 @@ +package dev.sargunv.maplibrecompose.material3 + +import androidx.compose.runtime.Composable +import dev.sargunv.maplibrecompose.material3.controls.ScaleBarMeasure + +@Composable internal actual fun systemDefaultPrimaryMeasure(): ScaleBarMeasure? = null +// TODO on macOS, there should be an API for this diff --git a/lib/maplibre-compose-material3/src/iosMain/kotlin/dev/sargunv/maplibrecompose/material3/util.ios.kt b/lib/maplibre-compose-material3/src/iosMain/kotlin/dev/sargunv/maplibrecompose/material3/util.ios.kt new file mode 100644 index 00000000..481501ed --- /dev/null +++ b/lib/maplibre-compose-material3/src/iosMain/kotlin/dev/sargunv/maplibrecompose/material3/util.ios.kt @@ -0,0 +1,19 @@ +package dev.sargunv.maplibrecompose.material3 + +import androidx.compose.runtime.Composable +import dev.sargunv.maplibrecompose.material3.controls.ScaleBarMeasure + +@Composable internal actual fun systemDefaultPrimaryMeasure(): ScaleBarMeasure? = null + +/* +TODO iOS developer: needs to be implemented in Swift with #available +internal actual fun scaleBareMeasurePreference(): ScaleBarMeasure? { + val system = NSLocale.currentLocale.measurementSystem + return when (userlocale.measurementSystem) { + Locale.MeasurementSystem.metric -> ScaleBarMeasure.Metric + Locale.MeasurementSystem.uk -> ScaleBarMeasure.YardsAndMiles + Locale.MeasurementSystem.us -> ScaleBarMeasure.FeetAndMiles + else -> null + } +} +*/ diff --git a/lib/maplibre-compose-material3/src/jsMain/kotlin/dev/sargunv/maplibrecompose/material3/util.js.kt b/lib/maplibre-compose-material3/src/jsMain/kotlin/dev/sargunv/maplibrecompose/material3/util.js.kt new file mode 100644 index 00000000..11f7e741 --- /dev/null +++ b/lib/maplibre-compose-material3/src/jsMain/kotlin/dev/sargunv/maplibrecompose/material3/util.js.kt @@ -0,0 +1,6 @@ +package dev.sargunv.maplibrecompose.material3 + +import androidx.compose.runtime.Composable +import dev.sargunv.maplibrecompose.material3.controls.ScaleBarMeasure + +@Composable internal actual fun systemDefaultPrimaryMeasure(): ScaleBarMeasure? = null