From 9553fd064fb08da9f5bffe68c2e830bbfe52822b Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Sun, 29 Dec 2024 19:45:23 -0800 Subject: [PATCH] feat: implement some desktop features --- .../demoapp/platform.android.kt | 6 + .../sargunv/maplibrecompose/demoapp/app.kt | 57 ++++--- .../maplibrecompose/demoapp/platform.kt | 6 + .../demoapp/platform.desktop.kt | 6 + .../maplibrecompose/demoapp/platform.ios.kt | 6 + docs/docs/index.md | 2 +- .../kotlin/dev/sargunv/maplibrejs/external.kt | 158 ++++++++++++++++++ .../kotlin/dev/sargunv/maplibrejs/util.kt | 90 +++++++++- lib/maplibre-compose-webview/build.gradle.kts | 1 + .../maplibrecompose/webview/WebviewBridge.kt | 95 +++++++++++ .../sargunv/maplibrecompose/webview/main.kt | 27 --- lib/maplibre-compose/build.gradle.kts | 9 +- .../maplibrecompose/compose/AndroidMapView.kt | 7 +- .../maplibrecompose/core/AndroidMap.kt | 89 +++++----- .../maplibrecompose/compose/CameraState.kt | 21 ++- .../compose/ComposableMapView.kt | 2 +- .../maplibrecompose/compose/MaplibreMap.kt | 50 ++++-- .../maplibrecompose/core/MaplibreMap.kt | 135 ++++++++++++--- .../maplibrecompose/core/OrnamentSettings.kt | 3 + .../maplibrecompose/compose/DesktopMapView.kt | 22 +-- .../maplibrecompose/core/WebviewBridge.kt | 57 +++++++ .../maplibrecompose/core/WebviewMap.kt | 139 +++++++++++++++ .../dev/sargunv/maplibrecompose/core/util.kt | 22 +++ .../maplibrecompose/compose/IosMapView.kt | 9 +- .../sargunv/maplibrecompose/core/IosMap.kt | 138 +++++++-------- 25 files changed, 912 insertions(+), 245 deletions(-) create mode 100644 demo-app/src/androidMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.android.kt create mode 100644 demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.kt create mode 100644 demo-app/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.desktop.kt create mode 100644 demo-app/src/iosMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.ios.kt create mode 100644 lib/maplibre-compose-webview/src/commonMain/kotlin/dev/sargunv/maplibrecompose/webview/WebviewBridge.kt delete mode 100644 lib/maplibre-compose-webview/src/commonMain/kotlin/dev/sargunv/maplibrecompose/webview/main.kt create mode 100644 lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/WebviewBridge.kt create mode 100644 lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/WebviewMap.kt create mode 100644 lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/util.kt diff --git a/demo-app/src/androidMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.android.kt b/demo-app/src/androidMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.android.kt new file mode 100644 index 00000000..bdbb62d7 --- /dev/null +++ b/demo-app/src/androidMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.android.kt @@ -0,0 +1,6 @@ +package dev.sargunv.maplibrecompose.demoapp + +actual object Platform { + actual val supportsBlending = true + actual val supportsFps = true +} 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 0175cb7b..0ec6ea00 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 @@ -51,17 +51,16 @@ import dev.sargunv.maplibrecompose.material3.controls.AttributionButton import dev.sargunv.maplibrecompose.material3.controls.DisappearingCompassButton import dev.sargunv.maplibrecompose.material3.controls.DisappearingScaleBar -private val DEMOS = - listOf( - MarkersDemo, - EdgeToEdgeDemo, - StyleSwitcherDemo, - ClusteredPointsDemo, - AnimatedLayerDemo, - CameraStateDemo, - CameraFollowDemo, - FrameRateDemo, - ) +private val DEMOS = buildList { + add(MarkersDemo) + if (Platform.supportsBlending) add(EdgeToEdgeDemo) + add(StyleSwitcherDemo) + add(ClusteredPointsDemo) + add(AnimatedLayerDemo) + add(CameraStateDemo) + add(CameraFollowDemo) + if (Platform.supportsFps) add(FrameRateDemo) +} @Composable fun DemoApp(navController: NavHostController = rememberNavController()) { @@ -121,8 +120,10 @@ fun DemoAppBar(demo: Demo, navigateUp: () -> Unit, alpha: Float = 1f) { } }, actions = { - IconButton(onClick = { showInfo = true }) { - Icon(imageVector = Icons.Default.Info, contentDescription = "Info") + if (Platform.supportsBlending) { + IconButton(onClick = { showInfo = true }) { + Icon(imageVector = Icons.Default.Info, contentDescription = "Info") + } } }, ) @@ -151,20 +152,24 @@ fun DemoMapControls( modifier: Modifier = Modifier, onCompassClick: () -> Unit = {}, ) { - Box(modifier = modifier.fillMaxSize().padding(8.dp)) { - DisappearingScaleBar(cameraState, modifier = Modifier.align(Alignment.TopStart)) - DisappearingCompassButton( - cameraState, - modifier = Modifier.align(Alignment.TopEnd), - onClick = onCompassClick, - ) - AttributionButton(styleState, modifier = Modifier.align(Alignment.BottomEnd)) + if (Platform.supportsBlending) { + Box(modifier = modifier.fillMaxSize().padding(8.dp)) { + DisappearingScaleBar(cameraState, modifier = Modifier.align(Alignment.TopStart)) + DisappearingCompassButton( + cameraState, + modifier = Modifier.align(Alignment.TopEnd), + onClick = onCompassClick, + ) + AttributionButton(styleState, modifier = Modifier.align(Alignment.BottomEnd)) + } } } fun DemoOrnamentSettings(padding: PaddingValues = PaddingValues(0.dp)) = - OrnamentSettings.AllDisabled.copy( - padding = padding, - isLogoEnabled = true, - logoAlignment = Alignment.BottomStart, - ) + if (Platform.supportsBlending) + OrnamentSettings.AllDisabled.copy( + padding = padding, + isLogoEnabled = true, + logoAlignment = Alignment.BottomStart, + ) + else OrnamentSettings.AllEnabled diff --git a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.kt b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.kt new file mode 100644 index 00000000..2452d949 --- /dev/null +++ b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.kt @@ -0,0 +1,6 @@ +package dev.sargunv.maplibrecompose.demoapp + +expect object Platform { + val supportsBlending: Boolean + val supportsFps: Boolean +} diff --git a/demo-app/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.desktop.kt b/demo-app/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.desktop.kt new file mode 100644 index 00000000..75c894a5 --- /dev/null +++ b/demo-app/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.desktop.kt @@ -0,0 +1,6 @@ +package dev.sargunv.maplibrecompose.demoapp + +actual object Platform { + actual val supportsBlending = false + actual val supportsFps = false +} diff --git a/demo-app/src/iosMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.ios.kt b/demo-app/src/iosMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.ios.kt new file mode 100644 index 00000000..bdbb62d7 --- /dev/null +++ b/demo-app/src/iosMain/kotlin/dev/sargunv/maplibrecompose/demoapp/platform.ios.kt @@ -0,0 +1,6 @@ +package dev.sargunv.maplibrecompose.demoapp + +actual object Platform { + actual val supportsBlending = true + actual val supportsFps = true +} diff --git a/docs/docs/index.md b/docs/docs/index.md index 5d6e06f9..8f91a1ea 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -28,7 +28,7 @@ Desktop support is implemented with [MapLibre GL JS][maplibre-js] and | ------------------------------------------------- | ------------------ | ------------------ | ------------------ | --- | | Render a map | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | | Load Compose resource URIs | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | -| Configure ornaments (compass, logo, attribution) | :white_check_mark: | :white_check_mark: | :x: | :x: | +| Configure ornaments (compass, logo, attribution) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | | Configure gestures (pan, zoom, rotate, pitch) | :white_check_mark: | :white_check_mark: | :x: | :x: | | Respond to a map click or long click | :white_check_mark: | :white_check_mark: | :x: | :x: | | Query visible map features | :white_check_mark: | :white_check_mark: | :x: | :x: | diff --git a/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/external.kt b/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/external.kt index 9894cf5e..7930dce9 100644 --- a/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/external.kt +++ b/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/external.kt @@ -7,10 +7,168 @@ import org.w3c.dom.HTMLElement /** [Map](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/) */ @JsName("Map") public external class Maplibre public constructor(options: MapOptions) { + public var repaint: Boolean + public var showCollisionBoxes: Boolean + public var showOverdrawInspector: Boolean + public var showPadding: Boolean + public var showTileBoundaries: Boolean + public val version: String + public fun setStyle(style: String) + + public fun remove() + + public fun getBearing(): Double + + public fun getCenter(): LngLat + + public fun getPitch(): Double + + public fun getZoom(): Double + + public fun setBearing(bearing: Double) + + public fun setCenter(center: LngLat) + + public fun setPitch(pitch: Double) + + public fun setZoom(zoom: Double) + + public fun setMaxZoom(max: Double) + + public fun setMinZoom(min: Double) + + public fun setMaxPitch(max: Double) + + public fun setMinPitch(min: Double) + + public fun jumpTo(options: JumpToOptions) + + public fun flyTo(options: FlyToOptions) + + public fun addControl(control: IControl, position: String) + + public fun removeControl(control: IControl) +} + +/** [LogoControl](https://maplibre.org/maplibre-gl-js/docs/API/classes/LogoControl/) */ +public external class LogoControl +public constructor(options: LogoControlOptions = definedExternally) : IControl { + override fun onAdd(map: Maplibre): HTMLElement + + override fun onRemove(map: Maplibre) +} + +/** + * [LogoControlOptions](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/LogoControlOptions/) + */ +public external interface LogoControlOptions { + public var compact: Boolean? +} + +/** [ScaleControl](https://maplibre.org/maplibre-gl-js/docs/API/classes/ScaleControl/) */ +public external class ScaleControl +public constructor(options: ScaleControlOptions = definedExternally) : IControl { + override fun onAdd(map: Maplibre): HTMLElement + + override fun onRemove(map: Maplibre) +} + +/** + * [ScaleControlOptions](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/ScaleControlOptions/) + */ +public external interface ScaleControlOptions { + public var maxWidth: Double? + public var unit: String? +} + +/** + * [AttributionControl](https://maplibre.org/maplibre-gl-js/docs/API/classes/AttributionControl/) + */ +public external class AttributionControl +public constructor(options: AttributionControlOptions = definedExternally) : IControl { + override fun onAdd(map: Maplibre): HTMLElement + + override fun onRemove(map: Maplibre) +} + +/** + * [AttributionControlOptions](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/AttributionControlOptions/) + */ +public external interface AttributionControlOptions { + public var compact: Boolean? + public var customAttribution: String? +} + +/** [NavigationControl](https://maplibre.org/maplibre-gl-js/docs/API/classes/NavigationControl/) */ +public external class NavigationControl +public constructor(options: NavigationControlOptions = definedExternally) : IControl { + override fun onAdd(map: Maplibre): HTMLElement + + override fun onRemove(map: Maplibre) +} + +/** + * [NavigationControlOptions](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/NavigationControlOptions/) + */ +public external interface NavigationControlOptions { + public var showCompass: Boolean? + public var showZoom: Boolean? + public var visualizePitch: Boolean? +} + +/** [IControl](https://maplibre.org/maplibre-gl-js/docs/API/interfaces/IControl/) */ +public external interface IControl { + public fun onAdd(map: Maplibre): HTMLElement + + public fun onRemove(map: Maplibre) +} + +/** [LngLat](https://maplibre.org/maplibre-gl-js/docs/API/classes/LngLat/) */ +public external class LngLat(public val lng: Double, public val lat: Double) { + public fun toArray(): DoubleArray +} + +/** [JumpToOptions](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/JumpToOptions/) */ +public sealed external interface JumpToOptions : CameraOptions { + public var padding: PaddingOptions? +} + +/** [FlyToOptions](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/FlyToOptions/) */ +public sealed external interface FlyToOptions : CameraOptions { + public var curve: Double? + public var maxDuration: Double? + public var minZoom: Double? + public var padding: PaddingOptions? + public var speed: Double? + public var screenSpeed: Double? +} + +/** [CameraOptions](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/CameraOptions/) */ +public sealed external interface CameraOptions : CenterZoomBearing { + public var around: LngLat? + public var pitch: Double? +} + +/** + * [CenterZoomBearing](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/CenterZoomBearing/) + */ +public sealed external interface CenterZoomBearing { + public var bearing: Double? + public var center: LngLat? + public var zoom: Double? +} + +/** [PaddingOptions](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/PaddingOptions/) */ +public sealed external interface PaddingOptions { + public var bottom: Double + public var left: Double + public var right: Double + public var top: Double } /** [MapOptions](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/MapOptions/) */ public sealed external interface MapOptions { public var container: HTMLElement + public var attributionControl: dynamic // false | AttributionControlOptions } diff --git a/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/util.kt b/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/util.kt index c32d21f5..221a6817 100644 --- a/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/util.kt +++ b/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/util.kt @@ -6,4 +6,92 @@ internal fun jso(): T = js("({})") as T internal inline fun jso(block: T.() -> Unit): T = jso().apply(block) -public fun MapOptions(container: HTMLElement): MapOptions = jso { this.container = container } +public fun MapOptions( + container: HTMLElement, + disableAttributionControl: Boolean = false, +): MapOptions = jso { + this.container = container + if (disableAttributionControl) { + this.attributionControl = false + } +} + +public fun LogoControlOptions(compact: Boolean? = null): LogoControlOptions = jso { + compact?.let { this.compact = it } +} + +public fun ScaleControlOptions( + maxWidth: Double? = null, + unit: String? = null, +): ScaleControlOptions = jso { + maxWidth?.let { this.maxWidth = it } + unit?.let { this.unit = it } +} + +public fun AttributionControlOptions( + compact: Boolean? = null, + customAttribution: String? = null, +): AttributionControlOptions = jso { + compact?.let { this.compact = it } + customAttribution?.let { this.customAttribution = it } +} + +public fun NavigationControlOptions( + showCompass: Boolean? = null, + showZoom: Boolean? = null, + visualizePitch: Boolean? = null, +): NavigationControlOptions = jso { + showCompass?.let { this.showCompass = it } + showZoom?.let { this.showZoom = it } + visualizePitch?.let { this.visualizePitch = it } +} + +public fun JumpToOptions( + center: LngLat? = null, + zoom: Double? = null, + bearing: Double? = null, + pitch: Double? = null, + padding: PaddingOptions? = null, +): JumpToOptions = jso { + center?.let { this.center = it } + zoom?.let { this.zoom = it } + bearing?.let { this.bearing = it } + pitch?.let { this.pitch = it } + padding?.let { this.padding = it } +} + +public fun PaddingOptions( + top: Double? = null, + bottom: Double? = null, + left: Double? = null, + right: Double? = null, +): PaddingOptions = jso { + top?.let { this.top = it } + bottom?.let { this.bottom = it } + left?.let { this.left = it } + right?.let { this.right = it } +} + +public fun FlyToOptions( + center: LngLat? = null, + zoom: Double? = null, + bearing: Double? = null, + pitch: Double? = null, + speed: Double? = null, + curve: Double? = null, + maxDuration: Double? = null, + minZoom: Double? = null, + padding: PaddingOptions? = null, + screenSpeed: Double? = null, +): FlyToOptions = jso { + center?.let { this.center = it } + zoom?.let { this.zoom = it } + bearing?.let { this.bearing = it } + pitch?.let { this.pitch = it } + speed?.let { this.speed = it } + curve?.let { this.curve = it } + maxDuration?.let { this.maxDuration = it } + minZoom?.let { this.minZoom = it } + padding?.let { this.padding = it } + screenSpeed?.let { this.screenSpeed = it } +} diff --git a/lib/maplibre-compose-webview/build.gradle.kts b/lib/maplibre-compose-webview/build.gradle.kts index 1fd0aa14..1989e309 100644 --- a/lib/maplibre-compose-webview/build.gradle.kts +++ b/lib/maplibre-compose-webview/build.gradle.kts @@ -9,6 +9,7 @@ kotlin { useEsModules() binaries.executable() + generateTypeScriptDefinitions() } sourceSets { diff --git a/lib/maplibre-compose-webview/src/commonMain/kotlin/dev/sargunv/maplibrecompose/webview/WebviewBridge.kt b/lib/maplibre-compose-webview/src/commonMain/kotlin/dev/sargunv/maplibrecompose/webview/WebviewBridge.kt new file mode 100644 index 00000000..47450afb --- /dev/null +++ b/lib/maplibre-compose-webview/src/commonMain/kotlin/dev/sargunv/maplibrecompose/webview/WebviewBridge.kt @@ -0,0 +1,95 @@ +@file:OptIn(ExperimentalJsExport::class) +@file:Suppress("unused") + +package dev.sargunv.maplibrecompose.webview + +import dev.sargunv.maplibrejs.AttributionControl +import dev.sargunv.maplibrejs.LogoControl +import dev.sargunv.maplibrejs.MapOptions +import dev.sargunv.maplibrejs.Maplibre +import dev.sargunv.maplibrejs.NavigationControl +import dev.sargunv.maplibrejs.NavigationControlOptions +import dev.sargunv.maplibrejs.ScaleControl +import kotlinx.browser.document +import org.w3c.dom.HTMLDivElement + +@JsExport +object WebviewBridge { + private var container: HTMLDivElement? = null + private lateinit var map: Maplibre + private lateinit var navigationControl: NavigationControl + private lateinit var logoControl: LogoControl + private lateinit var scaleControl: ScaleControl + private lateinit var attributionControl: AttributionControl + + fun init() { + container = + document.createElement("div").also { + it.setAttribute("style", "width: 100%; height: 100vh;") + document.body!!.appendChild(it) + } as HTMLDivElement + map = Maplibre(MapOptions(container = container!!, disableAttributionControl = true)) + navigationControl = NavigationControl(NavigationControlOptions(visualizePitch = true)) + logoControl = LogoControl() + scaleControl = ScaleControl() + attributionControl = AttributionControl() + } + + fun setStyleUri(styleUri: String) { + map.setStyle(styleUri) + } + + fun setDebugEnabled(enabled: Boolean) { + map.showCollisionBoxes = enabled + map.showPadding = enabled + map.showTileBoundaries = enabled + } + + fun setMaxZoom(maxZoom: Double) { + map.setMaxZoom(maxZoom) + } + + fun setMinZoom(minZoom: Double) { + map.setMinZoom(minZoom) + } + + fun setMaxPitch(maxPitch: Double) { + map.setMaxPitch(maxPitch) + } + + fun setMinPitch(minPitch: Double) { + map.setMinPitch(minPitch) + } + + fun addNavigationControl(position: String) { + map.addControl(navigationControl, position) + } + + fun removeNavigationControl() { + map.removeControl(navigationControl) + } + + fun addLogoControl(position: String) { + map.addControl(logoControl, position) + } + + fun removeLogoControl() { + map.removeControl(logoControl) + } + + fun addScaleControl(position: String) { + map.addControl(scaleControl, position) + } + + fun removeScaleControl() { + map.removeControl(scaleControl) + } + + fun addAttributionControl(position: String) { + map.addControl(attributionControl, position) + } + + fun removeAttributionControl() { + map.removeControl(attributionControl) + } +} diff --git a/lib/maplibre-compose-webview/src/commonMain/kotlin/dev/sargunv/maplibrecompose/webview/main.kt b/lib/maplibre-compose-webview/src/commonMain/kotlin/dev/sargunv/maplibrecompose/webview/main.kt deleted file mode 100644 index 40defa50..00000000 --- a/lib/maplibre-compose-webview/src/commonMain/kotlin/dev/sargunv/maplibrecompose/webview/main.kt +++ /dev/null @@ -1,27 +0,0 @@ -@file:OptIn(ExperimentalJsExport::class) - -package dev.sargunv.maplibrecompose.webview - -import dev.sargunv.maplibrejs.MapOptions -import dev.sargunv.maplibrejs.Maplibre -import kotlinx.browser.document -import org.w3c.dom.HTMLDivElement - -internal lateinit var map: Maplibre - -internal fun main() { - document.addEventListener( - "DOMContentLoaded", - { - val container = document.createElement("div") as HTMLDivElement - container.setAttribute("style", "width: 100%; height: 100vh;") - document.body!!.appendChild(container) - map = Maplibre(MapOptions(container = container)) - }, - ) -} - -@JsExport -fun setStyle(style: String) { - map.setStyle(style) -} diff --git a/lib/maplibre-compose/build.gradle.kts b/lib/maplibre-compose/build.gradle.kts index 47899ca8..c29f8ac5 100644 --- a/lib/maplibre-compose/build.gradle.kts +++ b/lib/maplibre-compose/build.gradle.kts @@ -82,11 +82,10 @@ kotlin { implementation(libs.maplibre.android.scalebar) } - desktopMain.apply { - dependencies { - implementation(compose.desktop.common) - implementation(libs.webview) - } + desktopMain.dependencies { + implementation(compose.desktop.common) + implementation(libs.kotlinx.coroutines.swing) + implementation(libs.webview) } commonTest.dependencies { diff --git a/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/compose/AndroidMapView.kt b/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/compose/AndroidMapView.kt index 1861365d..1edf0e38 100644 --- a/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/compose/AndroidMapView.kt +++ b/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/compose/AndroidMapView.kt @@ -14,6 +14,7 @@ import co.touchlab.kermit.Logger import dev.sargunv.maplibrecompose.core.AndroidMap import dev.sargunv.maplibrecompose.core.AndroidScaleBar import dev.sargunv.maplibrecompose.core.MaplibreMap +import kotlinx.coroutines.runBlocking import org.maplibre.android.MapLibre import org.maplibre.android.maps.MapView @@ -21,7 +22,7 @@ import org.maplibre.android.maps.MapView internal actual fun ComposableMapView( modifier: Modifier, styleUri: String, - update: (map: MaplibreMap) -> Unit, + update: suspend (map: MaplibreMap) -> Unit, onReset: () -> Unit, logger: Logger?, callbacks: MaplibreMap.Callbacks, @@ -29,7 +30,7 @@ internal actual fun ComposableMapView( AndroidMapView( modifier = modifier, styleUri = styleUri, - update = update, + update = { runBlocking { update(it) } }, onReset = onReset, logger = logger, callbacks = callbacks, @@ -80,8 +81,8 @@ internal fun AndroidMapView( map.layoutDir = layoutDir map.density = density map.callbacks = callbacks - map.styleUri = styleUri map.logger = logger + map.setStyleUri(styleUri) update(map) }, onReset = { diff --git a/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/AndroidMap.kt b/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/AndroidMap.kt index 7696f9c3..8d2e6bfd 100644 --- a/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/AndroidMap.kt +++ b/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/AndroidMap.kt @@ -55,7 +55,7 @@ internal class AndroidMap( internal var callbacks: MaplibreMap.Callbacks, logger: Logger?, styleUri: String, -) : MaplibreMap { +) : StandardMaplibreMap { internal var layoutDir: LayoutDirection = layoutDir set(value) { @@ -79,18 +79,15 @@ internal class AndroidMap( } } - override var styleUri: String = "" - set(value) { - if (field == value) return - logger?.i { "Setting style URI" } - callbacks.onStyleChanged(this, null) - val builder = MlnStyle.Builder().fromUri(value.correctedAndroidUri().toString()) - map.setStyle(builder) { - logger?.i { "Style finished loading" } - callbacks.onStyleChanged(this, AndroidStyle(it)) - } - field = value + override fun setStyleUri(styleUri: String) { + logger?.i { "Setting style URI" } + callbacks.onStyleChanged(this, null) + val builder = MlnStyle.Builder().fromUri(styleUri.correctedAndroidUri().toString()) + map.setStyle(builder) { + logger?.i { "Style finished loading" } + callbacks.onStyleChanged(this, AndroidStyle(it)) } + } init { map.addOnCameraMoveStartedListener { reason -> @@ -175,48 +172,38 @@ internal class AndroidMap( true } - map.setOnFpsChangedListener { onFpsChanged(it) } + map.setOnFpsChangedListener { fps -> callbacks.onFrame(fps) } - this.styleUri = styleUri + this.setStyleUri(styleUri) } - override var isDebugEnabled - get() = map.isDebugActive - set(value) { - map.isDebugActive = value - } - - override var onFpsChanged: (Double) -> Unit = { _ -> } + override fun setDebugEnabled(enabled: Boolean) { + map.isDebugActive = enabled + } - override var minPitch - get() = map.minPitch - set(value) { - map.setMinPitchPreference(value) - } + override fun setMinPitch(minPitch: Double) { + map.setMinPitchPreference(minPitch) + } - override var maxPitch - get() = map.maxPitch - set(value) { - map.setMaxPitchPreference(value) - } + override fun setMaxPitch(maxPitch: Double) { + map.setMaxPitchPreference(maxPitch) + } - override var minZoom - get() = map.minZoomLevel - set(value) { - map.setMinZoomPreference(value) - } + override fun setMinZoom(minZoom: Double) { + map.setMinZoomPreference(minZoom) + } - override var maxZoom - get() = map.maxZoomLevel - set(value) { - map.setMaxZoomPreference(value) - } + override fun setMaxZoom(maxZoom: Double) { + map.setMaxZoomPreference(maxZoom) + } - override val visibleBoundingBox: BoundingBox - get() = map.projection.visibleRegion.latLngBounds.toBoundingBox() + override fun getVisibleBoundingBox(): BoundingBox { + return map.projection.visibleRegion.latLngBounds.toBoundingBox() + } - override val visibleRegion: VisibleRegion - get() = map.projection.visibleRegion.toVisibleRegion() + override fun getVisibleRegion(): VisibleRegion { + return map.projection.visibleRegion.toVisibleRegion() + } override fun setMaximumFps(maximumFps: Int) = mapView.setMaximumFps(maximumFps) @@ -293,11 +280,13 @@ internal class AndroidMap( .build() } - override var cameraPosition: CameraPosition - get() = map.cameraPosition.toCameraPosition() - set(value) { - map.moveCamera(CameraUpdateFactory.newCameraPosition(value.toMLNCameraPosition())) - } + override fun getCameraPosition(): CameraPosition { + return map.cameraPosition.toCameraPosition() + } + + override fun setCameraPosition(cameraPosition: CameraPosition) { + map.moveCamera(CameraUpdateFactory.newCameraPosition(cameraPosition.toMLNCameraPosition())) + } override suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) = suspendCoroutine { cont -> diff --git a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/CameraState.kt b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/CameraState.kt index f2790fc5..d3092fad 100644 --- a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/CameraState.kt +++ b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/CameraState.kt @@ -19,6 +19,7 @@ import io.github.dellisd.spatialk.geojson.Position import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.runBlocking /** Remember a new [CameraState] in the initial state as given in [firstPosition]. */ @Composable @@ -31,7 +32,7 @@ public class CameraState internal constructor(firstPosition: CameraPosition) { internal var map: MaplibreMap? = null set(map) { if (map != null && map !== field) { - map.cameraPosition = position + runBlocking { map.asyncSetCameraPosition(position) } mapAttachSignal.trySend(map) } field = map @@ -48,7 +49,7 @@ public class CameraState internal constructor(firstPosition: CameraPosition) { public var position: CameraPosition get() = positionState.value set(value) { - map?.cameraPosition = value + runBlocking { map?.asyncSetCameraPosition(value) } positionState.value = value } @@ -88,7 +89,7 @@ public class CameraState internal constructor(firstPosition: CameraPosition) { * @throws IllegalStateException if the map is not initialized yet. See [awaitInitialized]. */ public fun screenLocationFromPosition(position: Position): DpOffset { - return requireMap().screenLocationFromPosition(position) + return runBlocking { requireMap().asyncGetScreenLocationFromPos(position) } } /** @@ -98,7 +99,7 @@ public class CameraState internal constructor(firstPosition: CameraPosition) { * @throws IllegalStateException if the map is not initialized yet. See [awaitInitialized]. */ public fun positionFromScreenLocation(offset: DpOffset): Position { - return requireMap().positionFromScreenLocation(offset) + return runBlocking { requireMap().asyncGetPosFromScreenLocation(offset) } } /** @@ -120,7 +121,9 @@ public class CameraState internal constructor(firstPosition: CameraPosition) { ): List { val predicateOrNull = predicate.takeUnless { it == const(true) }?.compile(ExpressionContext.None) - return map?.queryRenderedFeatures(offset, layerIds, predicateOrNull) ?: emptyList() + return runBlocking { + map?.asyncQueryRenderedFeatures(offset, layerIds, predicateOrNull) ?: emptyList() + } } /** @@ -141,7 +144,9 @@ public class CameraState internal constructor(firstPosition: CameraPosition) { ): List { val predicateOrNull = predicate.takeUnless { it == const(true) }?.compile(ExpressionContext.None) - return map?.queryRenderedFeatures(rect, layerIds, predicateOrNull) ?: emptyList() + return runBlocking { + map?.asyncQueryRenderedFeatures(rect, layerIds, predicateOrNull) ?: emptyList() + } } /** @@ -155,7 +160,7 @@ public class CameraState internal constructor(firstPosition: CameraPosition) { */ public fun queryVisibleBoundingBox(): BoundingBox { // TODO at some point, this should be refactored to State, just like the camera position - return requireMap().visibleBoundingBox + return runBlocking { requireMap().asyncGetVisibleBoundingBox() } } /** @@ -167,6 +172,6 @@ public class CameraState internal constructor(firstPosition: CameraPosition) { */ public fun queryVisibleRegion(): VisibleRegion { // TODO at some point, this should be refactored to State, just like the camera position - return requireMap().visibleRegion + return runBlocking { requireMap().asyncGetVisibleRegion() } } } diff --git a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/ComposableMapView.kt b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/ComposableMapView.kt index 2283d2b6..4ffcadff 100644 --- a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/ComposableMapView.kt +++ b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/ComposableMapView.kt @@ -9,7 +9,7 @@ import dev.sargunv.maplibrecompose.core.MaplibreMap internal expect fun ComposableMapView( modifier: Modifier, styleUri: String, - update: (map: MaplibreMap) -> Unit, + update: suspend (map: MaplibreMap) -> Unit, onReset: () -> Unit, logger: Logger?, callbacks: MaplibreMap.Callbacks, diff --git a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/MaplibreMap.kt b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/MaplibreMap.kt index fd8b106d..2d012cb2 100644 --- a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/MaplibreMap.kt +++ b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/MaplibreMap.kt @@ -15,6 +15,7 @@ import dev.sargunv.maplibrecompose.core.CameraMoveReason import dev.sargunv.maplibrecompose.core.GestureSettings import dev.sargunv.maplibrecompose.core.MaplibreMap import dev.sargunv.maplibrecompose.core.OrnamentSettings +import dev.sargunv.maplibrecompose.core.StandardMaplibreMap import dev.sargunv.maplibrecompose.core.Style import dev.sargunv.maplibrecompose.core.util.PlatformUtils import io.github.dellisd.spatialk.geojson.Position @@ -42,6 +43,9 @@ import kotlin.math.roundToInt * @param isDebugEnabled Whether the map debug information is shown. * @param maximumFps The maximum frame rate at which the map view is rendered, but it can't exceed * the ability of device hardware. + * + * Note: This parameter does not take effect on web and desktop. + * * @param logger kermit logger to use * @param content The map content additional to what is already part of the map as defined in the * base map style linked in [styleUri]. @@ -106,10 +110,11 @@ public fun MaplibreMap( remember(cameraState, styleState, styleComposition) { object : MaplibreMap.Callbacks { override fun onStyleChanged(map: MaplibreMap, style: Style?) { + map as StandardMaplibreMap styleState.attach(style) rememberedStyle = style cameraState.metersPerDpAtTargetState.value = - map.metersPerDpAtLatitude(map.cameraPosition.target.latitude) + map.metersPerDpAtLatitude(map.getCameraPosition().target.latitude) } override fun onCameraMoveStarted(map: MaplibreMap, reason: CameraMoveReason) { @@ -117,9 +122,10 @@ public fun MaplibreMap( } override fun onCameraMoved(map: MaplibreMap) { - cameraState.positionState.value = map.cameraPosition + map as StandardMaplibreMap + cameraState.positionState.value = map.getCameraPosition() cameraState.metersPerDpAtTargetState.value = - map.metersPerDpAtLatitude(map.cameraPosition.target.latitude) + map.metersPerDpAtLatitude(map.getCameraPosition().target.latitude) } override fun onCameraMoveEnded(map: MaplibreMap) {} @@ -133,6 +139,7 @@ public fun MaplibreMap( } override fun onClick(map: MaplibreMap, latLng: Position, offset: DpOffset) { + map as StandardMaplibreMap if (onMapClick(latLng, offset).consumed) return layerNodesInOrder().find { node -> val handle = node.onClick ?: return@find false @@ -147,6 +154,7 @@ public fun MaplibreMap( } override fun onLongClick(map: MaplibreMap, latLng: Position, offset: DpOffset) { + map as StandardMaplibreMap if (onMapLongClick(latLng, offset).consumed) return layerNodesInOrder().find { node -> val handle = node.onLongClick ?: return@find false @@ -159,6 +167,10 @@ public fun MaplibreMap( features.isNotEmpty() && handle(features).consumed } } + + override fun onFrame(fps: Double) { + onFrame(fps) + } } } @@ -167,15 +179,29 @@ public fun MaplibreMap( styleUri = styleUri, update = { map -> cameraState.map = map - map.onFpsChanged = onFrame - map.isDebugEnabled = isDebugEnabled - map.minZoom = zoomRange.start.toDouble() - map.maxZoom = zoomRange.endInclusive.toDouble() - map.minPitch = pitchRange.start.toDouble() - map.maxPitch = pitchRange.endInclusive.toDouble() - map.setGestureSettings(gestureSettings) - map.setOrnamentSettings(ornamentSettings) - map.setMaximumFps(maximumFps) + when (map) { + is StandardMaplibreMap -> { + map.setDebugEnabled(isDebugEnabled) + map.setMinZoom(zoomRange.start.toDouble()) + map.setMaxZoom(zoomRange.endInclusive.toDouble()) + map.setMinPitch(pitchRange.start.toDouble()) + map.setMaxPitch(pitchRange.endInclusive.toDouble()) + map.setGestureSettings(gestureSettings) + map.setOrnamentSettings(ornamentSettings) + map.setMaximumFps(maximumFps) + } + + else -> { + map.asyncSetDebugEnabled(isDebugEnabled) + map.asyncSetMinZoom(zoomRange.start.toDouble()) + map.asyncSetMaxZoom(zoomRange.endInclusive.toDouble()) + map.asyncSetMinPitch(pitchRange.start.toDouble()) + map.asyncSetMaxPitch(pitchRange.endInclusive.toDouble()) + map.asyncSetGestureSettings(gestureSettings) + map.asyncSetOrnamentSettings(ornamentSettings) + map.asyngSetMaximumFps(maximumFps) + } + } }, onReset = { cameraState.map = null diff --git a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/MaplibreMap.kt b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/MaplibreMap.kt index 34fc392f..84711a11 100644 --- a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/MaplibreMap.kt +++ b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/MaplibreMap.kt @@ -10,51 +10,51 @@ import io.github.dellisd.spatialk.geojson.Position import kotlin.time.Duration internal interface MaplibreMap { - var styleUri: String + suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) - var isDebugEnabled: Boolean + suspend fun asyncSetStyleUri(styleUri: String) - var cameraPosition: CameraPosition + suspend fun asyncSetDebugEnabled(enabled: Boolean) - var maxZoom: Double + suspend fun asyncGetCameraPosition(): CameraPosition - var minZoom: Double + suspend fun asyncSetCameraPosition(cameraPosition: CameraPosition) - var maxPitch: Double + suspend fun asyncSetMaxZoom(maxZoom: Double) - var minPitch: Double + suspend fun asyncSetMinZoom(minZoom: Double) - var onFpsChanged: (Double) -> Unit + suspend fun asyncSetMinPitch(minPitch: Double) - val visibleBoundingBox: BoundingBox + suspend fun asyncSetMaxPitch(maxPitch: Double) - val visibleRegion: VisibleRegion + suspend fun asyncGetVisibleBoundingBox(): BoundingBox - fun setMaximumFps(maximumFps: Int) + suspend fun asyncGetVisibleRegion(): VisibleRegion - fun setOrnamentSettings(value: OrnamentSettings) + suspend fun asyngSetMaximumFps(maximumFps: Int) - fun setGestureSettings(value: GestureSettings) + suspend fun asyncSetOrnamentSettings(value: OrnamentSettings) - suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) + suspend fun asyncSetGestureSettings(value: GestureSettings) - fun positionFromScreenLocation(offset: DpOffset): Position + suspend fun asyncGetPosFromScreenLocation(offset: DpOffset): Position - fun screenLocationFromPosition(position: Position): DpOffset + suspend fun asyncGetScreenLocationFromPos(position: Position): DpOffset - fun queryRenderedFeatures( + suspend fun asyncQueryRenderedFeatures( offset: DpOffset, layerIds: Set? = null, predicate: CompiledExpression? = null, ): List - fun queryRenderedFeatures( + suspend fun asyncQueryRenderedFeatures( rect: DpRect, layerIds: Set? = null, predicate: CompiledExpression? = null, ): List - fun metersPerDpAtLatitude(latitude: Double): Double + suspend fun asyncMetersPerDpAtLatitude(latitude: Double): Double interface Callbacks { fun onStyleChanged(map: MaplibreMap, style: Style?) @@ -68,5 +68,102 @@ internal interface MaplibreMap { fun onClick(map: MaplibreMap, latLng: Position, offset: DpOffset) fun onLongClick(map: MaplibreMap, latLng: Position, offset: DpOffset) + + fun onFrame(fps: Double) } } + +internal interface StandardMaplibreMap : MaplibreMap { + override suspend fun asyncSetStyleUri(styleUri: String) = setStyleUri(styleUri) + + override suspend fun asyncSetDebugEnabled(enabled: Boolean) = setDebugEnabled(enabled) + + override suspend fun asyncGetCameraPosition(): CameraPosition = getCameraPosition() + + override suspend fun asyncSetCameraPosition(cameraPosition: CameraPosition) = + setCameraPosition(cameraPosition) + + override suspend fun asyncSetMaxZoom(maxZoom: Double) = setMaxZoom(maxZoom) + + override suspend fun asyncSetMinZoom(minZoom: Double) = setMinZoom(minZoom) + + override suspend fun asyncSetMinPitch(minPitch: Double) = setMinPitch(minPitch) + + override suspend fun asyncSetMaxPitch(maxPitch: Double) = setMaxPitch(maxPitch) + + override suspend fun asyncGetVisibleBoundingBox(): BoundingBox = getVisibleBoundingBox() + + override suspend fun asyncGetVisibleRegion(): VisibleRegion = getVisibleRegion() + + override suspend fun asyngSetMaximumFps(maximumFps: Int) = setMaximumFps(maximumFps) + + override suspend fun asyncSetOrnamentSettings(value: OrnamentSettings) = + setOrnamentSettings(value) + + override suspend fun asyncSetGestureSettings(value: GestureSettings) = setGestureSettings(value) + + override suspend fun asyncGetPosFromScreenLocation(offset: DpOffset): Position = + positionFromScreenLocation(offset) + + override suspend fun asyncGetScreenLocationFromPos(position: Position): DpOffset = + screenLocationFromPosition(position) + + override suspend fun asyncQueryRenderedFeatures( + offset: DpOffset, + layerIds: Set?, + predicate: CompiledExpression?, + ): List = queryRenderedFeatures(offset, layerIds, predicate) + + override suspend fun asyncQueryRenderedFeatures( + rect: DpRect, + layerIds: Set?, + predicate: CompiledExpression?, + ): List = queryRenderedFeatures(rect, layerIds, predicate) + + override suspend fun asyncMetersPerDpAtLatitude(latitude: Double): Double = + metersPerDpAtLatitude(latitude) + + fun setStyleUri(styleUri: String) + + fun setDebugEnabled(enabled: Boolean) + + fun getCameraPosition(): CameraPosition + + fun setCameraPosition(cameraPosition: CameraPosition) + + fun setMaxZoom(maxZoom: Double) + + fun setMinZoom(minZoom: Double) + + fun setMinPitch(minPitch: Double) + + fun setMaxPitch(maxPitch: Double) + + fun getVisibleBoundingBox(): BoundingBox + + fun getVisibleRegion(): VisibleRegion + + fun setMaximumFps(maximumFps: Int) + + fun setOrnamentSettings(value: OrnamentSettings) + + fun setGestureSettings(value: GestureSettings) + + fun positionFromScreenLocation(offset: DpOffset): Position + + fun screenLocationFromPosition(position: Position): DpOffset + + fun queryRenderedFeatures( + offset: DpOffset, + layerIds: Set? = null, + predicate: CompiledExpression? = null, + ): List + + fun queryRenderedFeatures( + rect: DpRect, + layerIds: Set? = null, + predicate: CompiledExpression? = null, + ): List + + fun metersPerDpAtLatitude(latitude: Double): Double +} diff --git a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/OrnamentSettings.kt b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/OrnamentSettings.kt index af6b8784..b65c8370 100644 --- a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/OrnamentSettings.kt +++ b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/OrnamentSettings.kt @@ -9,6 +9,9 @@ import androidx.compose.ui.unit.dp * Defines which additional UI elements are displayed on top of the map. * * @param padding padding of the ornaments to the edge of the map. + * + * Note: this paarameter does not take effect on web and desktop. + * * @param isLogoEnabled whether to display the MapLibre logo. * @param logoAlignment where to place the MapLibre logo. * diff --git a/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/compose/DesktopMapView.kt b/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/compose/DesktopMapView.kt index ee72b037..f7ccd28f 100644 --- a/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/compose/DesktopMapView.kt +++ b/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/compose/DesktopMapView.kt @@ -3,6 +3,7 @@ package dev.sargunv.maplibrecompose.compose import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import co.touchlab.kermit.Logger import com.multiplatform.webview.jsbridge.IJsMessageHandler @@ -13,12 +14,14 @@ import com.multiplatform.webview.web.WebViewNavigator import com.multiplatform.webview.web.rememberWebViewNavigator import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData import dev.sargunv.maplibrecompose.core.MaplibreMap +import dev.sargunv.maplibrecompose.core.WebviewBridge +import dev.sargunv.maplibrecompose.core.WebviewMap @Composable internal actual fun ComposableMapView( modifier: Modifier, styleUri: String, - update: (map: MaplibreMap) -> Unit, + update: suspend (map: MaplibreMap) -> Unit, onReset: () -> Unit, logger: Logger?, callbacks: MaplibreMap.Callbacks, @@ -36,7 +39,7 @@ internal actual fun ComposableMapView( internal fun DesktopMapView( modifier: Modifier, styleUri: String, - update: (map: MaplibreMap) -> Unit, + update: suspend (map: MaplibreMap) -> Unit, onReset: () -> Unit, logger: Logger?, callbacks: MaplibreMap.Callbacks, @@ -46,10 +49,6 @@ internal fun DesktopMapView( val navigator = rememberWebViewNavigator() val jsBridge = rememberWebViewJsBridge(navigator) - LaunchedEffect(jsBridge) { - // jsBridge.register() - } - WebView( state = state, modifier = modifier.fillMaxWidth(), @@ -61,10 +60,13 @@ internal fun DesktopMapView( if (state.isLoading) return - LaunchedEffect(styleUri) { - // TODO: prevent script injection - navigator.evaluateJavaScript("globalThis['maplibre-compose-webview'].setStyle('$styleUri')") - } + val map = remember(state) { WebviewMap(WebviewBridge(state.nativeWebView, "WebviewBridge")) } + + LaunchedEffect(map) { map.init() } + + LaunchedEffect(map, styleUri) { map.asyncSetStyleUri(styleUri) } + + LaunchedEffect(map, update) { update(map) } } internal class MessageHandler( diff --git a/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/WebviewBridge.kt b/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/WebviewBridge.kt new file mode 100644 index 00000000..3db74681 --- /dev/null +++ b/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/WebviewBridge.kt @@ -0,0 +1,57 @@ +package dev.sargunv.maplibrecompose.core + +import dev.datlag.kcef.KCEFBrowser +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.encodeToJsonElement + +internal class WebviewBridge( + private val browser: KCEFBrowser, + objectName: String, + moduleName: String = "maplibre-compose-webview", +) { + private val instance = + "globalThis[${JsonPrimitive(moduleName)}][${JsonPrimitive(objectName)}].getInstance()" + + suspend inline fun get(propertyName: String): T { + println("Getting $propertyName") + val fn = JsonPrimitive(propertyName) + val result = browser.evaluateJavaScript("$instance[$fn]") + return Json.decodeFromString(result!!) + } + + suspend inline fun set(propertyName: String, param: T) { + println("Setting $propertyName") + val fn = JsonPrimitive(propertyName) + val arg = Json.encodeToJsonElement(param) + browser.evaluateJavaScript("$instance[$fn] = ($arg)") + } + + suspend inline fun callVoid(methodName: String, param: I) { + println("Calling $methodName") + val fn = JsonPrimitive(methodName) + val arg = Json.encodeToJsonElement(param) + browser.evaluateJavaScript("$instance[$fn]($arg)") + } + + suspend inline fun callVoid(methodName: String) { + println("Calling $methodName") + val fn = JsonPrimitive(methodName) + browser.evaluateJavaScript("$instance[$fn]()") + } + + suspend inline fun call(methodName: String, param: I): O { + println("Calling $methodName") + val fn = JsonPrimitive(methodName) + val arg = Json.encodeToJsonElement(param) + val result = browser.evaluateJavaScript("$instance[$fn]($arg)") + return Json.decodeFromString(result!!) + } + + suspend inline fun call(methodName: String): O { + println("Calling $methodName") + val fn = JsonPrimitive(methodName) + val result = browser.evaluateJavaScript("$instance[$fn]()") + return Json.decodeFromString(result!!) + } +} diff --git a/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/WebviewMap.kt b/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/WebviewMap.kt new file mode 100644 index 00000000..1a42e633 --- /dev/null +++ b/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/WebviewMap.kt @@ -0,0 +1,139 @@ +package dev.sargunv.maplibrecompose.core + +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.LayoutDirection +import dev.sargunv.maplibrecompose.expressions.ast.CompiledExpression +import dev.sargunv.maplibrecompose.expressions.value.BooleanValue +import io.github.dellisd.spatialk.geojson.BoundingBox +import io.github.dellisd.spatialk.geojson.Feature +import io.github.dellisd.spatialk.geojson.Position +import kotlin.time.Duration + +internal class WebviewMap(private val bridge: WebviewBridge) : MaplibreMap { + suspend fun init() { + bridge.callVoid("init") + } + + override suspend fun asyncSetStyleUri(styleUri: String) { + bridge.callVoid("setStyleUri", styleUri) + } + + override suspend fun asyncSetDebugEnabled(enabled: Boolean) { + bridge.callVoid("setDebugEnabled", enabled) + } + + override suspend fun asyncGetCameraPosition(): CameraPosition { + return CameraPosition() + } + + override suspend fun asyncSetCameraPosition(cameraPosition: CameraPosition) {} + + override suspend fun asyncSetMaxZoom(maxZoom: Double) { + bridge.callVoid("setMaxZoom", maxZoom) + } + + override suspend fun asyncSetMinZoom(minZoom: Double) { + bridge.callVoid("setMinZoom", minZoom) + } + + override suspend fun asyncSetMinPitch(minPitch: Double) { + bridge.callVoid("setMinPitch", minPitch) + } + + override suspend fun asyncSetMaxPitch(maxPitch: Double) { + bridge.callVoid("setMaxPitch", maxPitch) + } + + override suspend fun asyncGetVisibleBoundingBox(): BoundingBox { + return BoundingBox(Position(0.0, 0.0), Position(0.0, 0.0)) + } + + override suspend fun asyncGetVisibleRegion(): VisibleRegion { + return VisibleRegion( + Position(0.0, 0.0), + Position(0.0, 0.0), + Position(0.0, 0.0), + Position(0.0, 0.0), + ) + } + + override suspend fun asyngSetMaximumFps(maximumFps: Int) { + // Not supported on web + } + + private var compassPosition: String? = null + private var logoPosition: String? = null + private var scalePosition: String? = null + private var attributionPosition: String? = null + + override suspend fun asyncSetOrnamentSettings(value: OrnamentSettings) { + val layoutDir = LayoutDirection.Ltr // TODO: Get from composition + + val desiredCompassPosition = + if (value.isCompassEnabled) value.compassAlignment.toControlPosition(layoutDir) else null + val desiredLogoPosition = + if (value.isLogoEnabled) value.logoAlignment.toControlPosition(layoutDir) else null + val desiredScalePosition = + if (value.isScaleBarEnabled) value.scaleBarAlignment.toControlPosition(layoutDir) else null + val desiredAttributionPosition = + if (value.isAttributionEnabled) value.attributionAlignment.toControlPosition(layoutDir) + else null + + if (compassPosition != desiredCompassPosition) { + if (desiredCompassPosition == null) bridge.callVoid("removeCompassControl") + else bridge.callVoid("addNavigationControl", desiredCompassPosition) + compassPosition = desiredCompassPosition + } + + if (logoPosition != desiredLogoPosition) { + if (desiredLogoPosition == null) bridge.callVoid("removeLogoControl") + else bridge.callVoid("addLogoControl", desiredLogoPosition) + logoPosition = desiredLogoPosition + } + + if (scalePosition != desiredScalePosition) { + if (desiredScalePosition == null) bridge.callVoid("removeScaleControl") + else bridge.callVoid("addScaleControl", desiredScalePosition) + scalePosition = desiredScalePosition + } + + if (attributionPosition != desiredAttributionPosition) { + if (desiredAttributionPosition == null) bridge.callVoid("removeAttributionControl") + else bridge.callVoid("addAttributionControl", desiredAttributionPosition) + attributionPosition = desiredAttributionPosition + } + } + + override suspend fun asyncSetGestureSettings(value: GestureSettings) {} + + override suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) {} + + override suspend fun asyncGetPosFromScreenLocation(offset: DpOffset): Position { + return Position(0.0, 0.0) + } + + override suspend fun asyncGetScreenLocationFromPos(position: Position): DpOffset { + return DpOffset.Zero + } + + override suspend fun asyncQueryRenderedFeatures( + offset: DpOffset, + layerIds: Set?, + predicate: CompiledExpression?, + ): List { + return emptyList() + } + + override suspend fun asyncQueryRenderedFeatures( + rect: DpRect, + layerIds: Set?, + predicate: CompiledExpression?, + ): List { + return emptyList() + } + + override suspend fun asyncMetersPerDpAtLatitude(latitude: Double): Double { + return 0.0 + } +} diff --git a/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/util.kt b/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/util.kt new file mode 100644 index 00000000..2bd2065b --- /dev/null +++ b/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/util.kt @@ -0,0 +1,22 @@ +package dev.sargunv.maplibrecompose.core + +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection + +internal fun Alignment.toControlPosition(layoutDir: LayoutDirection): String { + val (x, y) = align(IntSize(1, 1), IntSize(2, 2), layoutDir) + val h = + when (x) { + 0 -> "left" + 1 -> "right" + else -> error("Invalid alignment") + } + val v = + when (y) { + 0 -> "top" + 1 -> "bottom" + else -> error("Invalid alignment") + } + return "$v-$h" +} diff --git a/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/compose/IosMapView.kt b/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/compose/IosMapView.kt index 94cf9771..0e3da6f4 100644 --- a/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/compose/IosMapView.kt +++ b/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/compose/IosMapView.kt @@ -21,6 +21,7 @@ import co.touchlab.kermit.Logger import cocoapods.MapLibre.MLNMapView import dev.sargunv.maplibrecompose.core.IosMap import dev.sargunv.maplibrecompose.core.MaplibreMap +import kotlinx.coroutines.runBlocking import platform.CoreGraphics.CGRectMake import platform.CoreGraphics.CGSizeMake import platform.Foundation.NSURL @@ -29,7 +30,7 @@ import platform.Foundation.NSURL internal actual fun ComposableMapView( modifier: Modifier, styleUri: String, - update: (map: MaplibreMap) -> Unit, + update: suspend (map: MaplibreMap) -> Unit, onReset: () -> Unit, logger: Logger?, callbacks: MaplibreMap.Callbacks, @@ -37,7 +38,7 @@ internal actual fun ComposableMapView( IosMapView( modifier = modifier, styleUri = styleUri, - update = update, + update = { runBlocking { update(it) } }, onReset = onReset, logger = logger, callbacks = callbacks, @@ -97,9 +98,9 @@ internal fun IosMapView( map.density = density map.insetPadding = insetPadding map.callbacks = callbacks - map.styleUri = styleUri map.logger = logger - update(map) + map.setStyleUri(styleUri) + runBlocking { update(map) } }, onReset = { currentOnReset() diff --git a/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/IosMap.kt b/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/IosMap.kt index a5527b6d..202f8de0 100644 --- a/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/IosMap.kt +++ b/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/IosMap.kt @@ -90,21 +90,12 @@ internal class IosMap( internal var insetPadding: PaddingValues, internal var callbacks: MaplibreMap.Callbacks, internal var logger: Logger?, -) : MaplibreMap { +) : StandardMaplibreMap { // hold strong references to things that the sdk keeps weak references to private val gestures = mutableListOf>() private val delegate: Delegate - override var styleUri: String = "" - set(value) { - if (field == value) return - logger?.i { "Setting style URI" } - callbacks.onStyleChanged(this, null) - mapView.setStyleURL(NSURL(string = value)) - field = value - } - init { mapView.automaticallyAdjustsContentInset = false @@ -215,10 +206,16 @@ internal class IosMap( val time = timeSource.markNow() val duration = time - lastFrameTime lastFrameTime = time - map.onFpsChanged(1.0 / duration.toDouble(DurationUnit.SECONDS)) + map.callbacks.onFrame(1.0 / duration.toDouble(DurationUnit.SECONDS)) } } + override fun setStyleUri(styleUri: String) { + logger?.i { "Setting style URI" } + callbacks.onStyleChanged(this, null) + mapView.setStyleURL(NSURL(string = styleUri)) + } + internal class Gesture( val recognizer: T, val isCooperative: Boolean = true, @@ -247,55 +244,46 @@ internal class IosMap( } } - override var isDebugEnabled: Boolean - get() = mapView.debugMask != 0uL - set(value) { - mapView.debugMask = - if (value) - MLNMapDebugTileBoundariesMask or - MLNMapDebugTileInfoMask or - MLNMapDebugTimestampsMask or - MLNMapDebugCollisionBoxesMask - else 0uL - } + override fun setDebugEnabled(enabled: Boolean) { + mapView.debugMask = + if (enabled) + MLNMapDebugTileBoundariesMask or + MLNMapDebugTileInfoMask or + MLNMapDebugTimestampsMask or + MLNMapDebugCollisionBoxesMask + else 0uL + } - override var minPitch - get() = mapView.minimumPitch - set(value) { - mapView.minimumPitch = value - } + override fun setMinPitch(minPitch: Double) { + mapView.minimumPitch = minPitch + } - override var maxPitch - get() = mapView.maximumPitch - set(value) { - mapView.maximumPitch = value - } + override fun setMaxPitch(maxPitch: Double) { + mapView.maximumPitch = maxPitch + } - override var minZoom - get() = mapView.minimumZoomLevel - set(value) { - mapView.minimumZoomLevel = value - } + override fun setMinZoom(minZoom: Double) { + mapView.minimumZoomLevel = minZoom + } - override var maxZoom - get() = mapView.maximumZoomLevel - set(value) { - mapView.maximumZoomLevel = value - } + override fun setMaxZoom(maxZoom: Double) { + mapView.maximumZoomLevel = maxZoom + } - override val visibleBoundingBox: BoundingBox - get() = mapView.visibleCoordinateBounds.toBoundingBox() - - override val visibleRegion: VisibleRegion - get() = - size.useContents { - VisibleRegion( - farLeft = positionFromScreenLocation(DpOffset(x = 0.dp, y = 0.dp)), - farRight = positionFromScreenLocation(DpOffset(x = width.dp, y = 0.dp)), - nearLeft = positionFromScreenLocation(DpOffset(x = 0.dp, y = height.dp)), - nearRight = positionFromScreenLocation(DpOffset(x = width.dp, y = height.dp)), - ) - } + override fun getVisibleBoundingBox(): BoundingBox { + return mapView.visibleCoordinateBounds.toBoundingBox() + } + + override fun getVisibleRegion(): VisibleRegion { + return size.useContents { + VisibleRegion( + farLeft = positionFromScreenLocation(DpOffset(x = 0.dp, y = 0.dp)), + farRight = positionFromScreenLocation(DpOffset(x = width.dp, y = 0.dp)), + nearLeft = positionFromScreenLocation(DpOffset(x = 0.dp, y = height.dp)), + nearRight = positionFromScreenLocation(DpOffset(x = width.dp, y = height.dp)), + ) + } + } override fun setMaximumFps(maximumFps: Int) { mapView.preferredFramesPerSecond = maximumFps.toLong() @@ -411,30 +399,24 @@ internal class IosMap( } } - override var cameraPosition: CameraPosition - get() = - mapView.camera.toCameraPosition( - paddingValues = - mapView.cameraEdgeInsets.useContents { - PaddingValues.Absolute( - left = left.dp, - top = top.dp, - right = right.dp, - bottom = bottom.dp, - ) - } - ) - set(value) { - mapView.setCamera( - value.toMLNMapCamera(), - withDuration = 0.0, - animationTimingFunction = null, - edgePadding = value.padding.toEdgeInsets(), - completionHandler = null, - ) - } + override fun getCameraPosition(): CameraPosition { + return mapView.camera.toCameraPosition( + paddingValues = + mapView.cameraEdgeInsets.useContents { + PaddingValues.Absolute(left = left.dp, top = top.dp, right = right.dp, bottom = bottom.dp) + } + ) + } - override var onFpsChanged: (Double) -> Unit = { _ -> } + override fun setCameraPosition(cameraPosition: CameraPosition) { + mapView.setCamera( + cameraPosition.toMLNMapCamera(), + withDuration = 0.0, + animationTimingFunction = null, + edgePadding = cameraPosition.padding.toEdgeInsets(), + completionHandler = null, + ) + } private fun PaddingValues.toEdgeInsets(): CValue = UIEdgeInsetsMake(