From d21d6ebb2a5fecbfdf472e5b1d6956162fb0b9d3 Mon Sep 17 00:00:00 2001 From: Alexander Nozik Date: Sun, 1 Oct 2023 14:05:03 +0300 Subject: [PATCH] Add (not working yet) JS implementation --- build.gradle.kts | 1 - demo/maps-js/build.gradle.kts | 26 +++ demo/maps-js/src/jsMain/kotlin/Main.kt | 172 ++++++++++++++++++ demo/maps-js/src/jsMain/resources/index.html | 13 ++ gradle.properties | 2 +- maps-kt-compose/build.gradle.kts | 14 +- .../compose/OpenStreetMapTileProvider.kt | 91 +++++++++ maps-kt-core/build.gradle.kts | 1 + .../sciprog/maps/features/CanvasState.kt | 5 +- .../sciprog/maps/features/FeatureDrawScope.kt | 6 +- settings.gradle.kts | 3 +- trajectory-kt/build.gradle.kts | 2 +- 12 files changed, 327 insertions(+), 9 deletions(-) create mode 100644 demo/maps-js/build.gradle.kts create mode 100644 demo/maps-js/src/jsMain/kotlin/Main.kt create mode 100644 demo/maps-js/src/jsMain/resources/index.html create mode 100644 maps-kt-compose/src/jsMain/kotlin/compose/OpenStreetMapTileProvider.kt diff --git a/build.gradle.kts b/build.gradle.kts index a461ddb..11adfc1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,7 +37,6 @@ ksciencePublish { subprojects { repositories { - maven("https://maven.pkg.jetbrains.space/mipt-npm/p/sci/dev") google() mavenCentral() maven("https://repo.kotlin.link") diff --git a/demo/maps-js/build.gradle.kts b/demo/maps-js/build.gradle.kts new file mode 100644 index 0000000..3f46b2c --- /dev/null +++ b/demo/maps-js/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") +} + +val ktorVersion: String by rootProject.extra + +kotlin { + js { + browser() + binaries.executable() + } + sourceSets { + val jsMain by getting { + dependencies { + implementation(projects.mapsKtCompose) + implementation(compose.runtime) + implementation(compose.html.core) + } + } + } +} + +compose { + web {} +} \ No newline at end of file diff --git a/demo/maps-js/src/jsMain/kotlin/Main.kt b/demo/maps-js/src/jsMain/kotlin/Main.kt new file mode 100644 index 0000000..f67c8df --- /dev/null +++ b/demo/maps-js/src/jsMain/kotlin/Main.kt @@ -0,0 +1,172 @@ +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.PointerMatcher +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.font.createFontFamilyResolver +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import center.sciprog.attributes.Attributes +import center.sciprog.maps.compose.* +import center.sciprog.maps.coordinates.GeodeticMapCoordinates +import center.sciprog.maps.coordinates.Gmc +import center.sciprog.maps.coordinates.kilometers +import center.sciprog.maps.features.* +import io.ktor.client.HttpClient +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.jetbrains.compose.web.renderComposable +import space.kscience.kmath.geometry.Angle +import space.kscience.kmath.geometry.degrees +import space.kscience.kmath.geometry.radians +import kotlin.math.PI +import kotlin.random.Random + +public fun GeodeticMapCoordinates.toShortString(): String = + "${(latitude.degrees).toString().take(6)}:${(longitude.degrees).toString().take(6)}" + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun App() { + + val scope = rememberCoroutineScope() + + val mapTileProvider = remember { + OpenStreetMapTileProvider( + client = HttpClient(), + ) + } + + val centerCoordinates = MutableStateFlow(null) + + val pointOne = 55.568548 to 37.568604 + val pointTwo = 55.929444 to 37.518434 +// val pointThree = 60.929444 to 37.518434 + + MapView( + mapTileProvider = mapTileProvider, + config = ViewConfig( + onViewChange = { centerCoordinates.value = focus }, + onClick = { _, viewPoint -> + println(viewPoint) + } + ) + ) { + +// icon(pointOne, Icons.Filled.Home) + + val marker1 = rectangle(55.744 to 38.614, size = DpSize(10.dp, 10.dp)) + .color(Color.Magenta) + val marker2 = rectangle(55.8 to 38.5, size = DpSize(10.dp, 10.dp)) + .color(Color.Magenta) + val marker3 = rectangle(56.0 to 38.5, size = DpSize(10.dp, 10.dp)) + .color(Color.Magenta) + + draggableLine(marker1, marker2, id = "line 1").color(Color.Red).onClick { + println("line 1 clicked") + } + draggableLine(marker2, marker3, id = "line 2").color(Color.DarkGray).onClick { + println("line 2 clicked") + } + draggableLine(marker3, marker1, id = "line 3").color(Color.Blue).onClick { + println("line 3 clicked") + } + + + multiLine( + points = listOf( + 55.742465 to 37.615812, + 55.742713 to 37.616370, + 55.742815 to 37.616659, + 55.742320 to 37.617132, + 55.742086 to 37.616566, + 55.741715 to 37.616716 + ), + ) + + //remember feature ref + val circleId = circle( + centerCoordinates = pointTwo, + ) + scope.launch { + while (isActive) { + delay(200) + circleId.color(Color(Random.nextFloat(), Random.nextFloat(), Random.nextFloat())) + } + } + + arc(pointOne, 10.0.kilometers, (PI / 4).radians, -Angle.pi / 2) + + + line(pointOne, pointTwo, id = "line") + text(pointOne, "Home", font = { size = 32f }) + + + pixelMap( + space.Rectangle( + Gmc(latitude = 55.58461879539754.degrees, longitude = 37.8746197303493.degrees), + Gmc(latitude = 55.442792937592415.degrees, longitude = 38.132240805463844.degrees) + ), + 0.005.degrees, + 0.005.degrees + ) { gmc -> + Color( + red = ((gmc.latitude + Angle.piDiv2).degrees * 10 % 1f).toFloat(), + green = ((gmc.longitude + Angle.pi).degrees * 10 % 1f).toFloat(), + blue = 0f, + alpha = 0.3f + ) + } + + centerCoordinates.filterNotNull().onEach { + group(id = "center") { + circle(center = it, id = "circle", size = 1.dp).color(Color.Blue) + text(position = it, it.toShortString(), id = "text").color(Color.Blue) + } + }.launchIn(scope) + + //Add click listeners for all polygons + forEachWithType> { ref -> + ref.onClick(PointerMatcher.Primary) { + println("Click on ${ref.id}") + //draw in top-level scope + with(this@MapView) { + multiLine( + ref.resolve().points, + attributes = Attributes(ZAttribute, 10f), + id = "selected", + ).modifyAttribute(StrokeAttribute, 4f).color(Color.Magenta) + } + } + } + } + +} + + +fun main() { + renderComposable(rootElementId = "root") { + CompositionLocalProvider( + LocalDensity provides Density(1.0f), + LocalLayoutDirection provides LayoutDirection.Ltr, +// LocalViewConfiguration provides DefaultViewConfiguration(Density(1.0f)), +// LocalInputModeManager provides InputModeManagerObject, + LocalFontFamilyResolver provides createFontFamilyResolver() + ) { + App() + } + } +} diff --git a/demo/maps-js/src/jsMain/resources/index.html b/demo/maps-js/src/jsMain/resources/index.html new file mode 100644 index 0000000..225341b --- /dev/null +++ b/demo/maps-js/src/jsMain/resources/index.html @@ -0,0 +1,13 @@ + + + + + Maps-kt demo + + + + +
+ + + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 5b599e7..043026b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ kotlin.code.style=official -compose.version=1.5.1 +compose.version=1.5.2 org.jetbrains.compose.experimental.jscanvas.enabled=true agp.version=8.1.0 diff --git a/maps-kt-compose/build.gradle.kts b/maps-kt-compose/build.gradle.kts index 7731254..e266367 100644 --- a/maps-kt-compose/build.gradle.kts +++ b/maps-kt-compose/build.gradle.kts @@ -19,9 +19,21 @@ kotlin { api(projects.mapsKtFeatures) api(compose.foundation) api(project.dependencies.platform(spclibs.ktor.bom)) - api("io.ktor:ktor-client-core") } } + + getByName("jvmMain"){ + dependencies { + api("io.ktor:ktor-client-cio") + } + } + + getByName("jsMain"){ + dependencies { + api("io.ktor:ktor-client-js") + } + } + getByName("jvmTest") { dependencies { implementation("io.ktor:ktor-client-cio") diff --git a/maps-kt-compose/src/jsMain/kotlin/compose/OpenStreetMapTileProvider.kt b/maps-kt-compose/src/jsMain/kotlin/compose/OpenStreetMapTileProvider.kt new file mode 100644 index 0000000..f6a6941 --- /dev/null +++ b/maps-kt-compose/src/jsMain/kotlin/compose/OpenStreetMapTileProvider.kt @@ -0,0 +1,91 @@ +package center.sciprog.maps.compose + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.readBytes +import io.ktor.http.Url +import io.ktor.util.decodeBase64Bytes +import io.ktor.util.encodeBase64 +import kotlinx.browser.window +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import org.jetbrains.skia.Image +import org.w3c.dom.Storage + +/** + * A [MapTileProvider] based on Open Street Map API. With in-memory and file cache + */ +public class OpenStreetMapTileProvider( + private val client: HttpClient, + private val storage: Storage = window.localStorage, + parallelism: Int = 4, + cacheCapacity: Int = 200, + private val osmBaseUrl: String = "https://tile.openstreetmap.org", +) : MapTileProvider { + private val semaphore = Semaphore(parallelism) + private val cache = LruCache>(cacheCapacity) + + private fun TileId.osmUrl() = Url("$osmBaseUrl/${zoom}/${i}/${j}.png") + + private fun TileId.imageName() = "${zoom}/${i}/${j}.png" + + private fun TileId.readImage() = storage.getItem(imageName()) + + /** + * Download and cache the tile image + */ + private fun CoroutineScope.downloadImageAsync(id: TileId): Deferred = async { + + id.readImage()?.let { imageString -> + try { + return@async Image.makeFromEncoded(imageString.decodeBase64Bytes()) + } catch (ex: Exception) { + logger.debug { "Failed to load image from $imageString" } + storage.removeItem(id.imageName()) + } + } + + //semaphore works only for actual download + semaphore.withPermit { + val url = id.osmUrl() + val byteArray = client.get(url).readBytes() + logger.debug { "Finished downloading map tile with id $id from $url" } + val imageName = id.imageName() + logger.debug { "Caching map tile $id to $imageName" } + storage.setItem(imageName, byteArray.encodeBase64()) + Image.makeFromEncoded(byteArray) + } + } + + override fun CoroutineScope.loadTileAsync( + tileId: TileId, + ): Deferred { + + //start image download + val imageDeferred: Deferred = cache.getOrPut(tileId) { + downloadImageAsync(tileId) + } + + //collect the result asynchronously + return async { + val image: Image = runCatching { imageDeferred.await() }.onFailure { + if (it !is CancellationException) { + logger.error(it) { "Failed to load tile image with id=$tileId" } + } + cache.remove(tileId) + }.getOrThrow() + + MapTile(tileId, image) + } + } + + + public companion object { + private val logger = KotlinLogging.logger("OpenStreetMapCache") + } +} \ No newline at end of file diff --git a/maps-kt-core/build.gradle.kts b/maps-kt-core/build.gradle.kts index a7f9a0e..b5ecff3 100644 --- a/maps-kt-core/build.gradle.kts +++ b/maps-kt-core/build.gradle.kts @@ -8,6 +8,7 @@ val kmathVersion: String by rootProject.extra kscience{ jvm() js() + native() useSerialization() dependencies{ diff --git a/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/CanvasState.kt b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/CanvasState.kt index 7309bbb..1572b3c 100644 --- a/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/CanvasState.kt +++ b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/CanvasState.kt @@ -1,6 +1,9 @@ package center.sciprog.maps.features -import androidx.compose.runtime.* +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.* diff --git a/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/FeatureDrawScope.kt b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/FeatureDrawScope.kt index e5998c3..b2f8d79 100644 --- a/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/FeatureDrawScope.kt +++ b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/FeatureDrawScope.kt @@ -48,11 +48,11 @@ public class ComposeFeatureDrawScope( drawScope: DrawScope, state: CanvasState, private val painterCache: Map, Painter>, - private val textMeasurer: TextMeasurer, + private val textMeasurer: TextMeasurer?, ) : FeatureDrawScope(state), DrawScope by drawScope { override fun drawText(text: String, position: Offset, attributes: Attributes) { try { - drawText(textMeasurer, text, position) + drawText(textMeasurer?: error("Text measurer not defined"), text, position) } catch (ex: Exception) { logger.error(ex) { "Failed to measure text" } } @@ -77,7 +77,7 @@ public fun FeatureCanvas( modifier: Modifier = Modifier, draw: FeatureDrawScope.() -> Unit = {}, ) { - val textMeasurer = rememberTextMeasurer(200) + val textMeasurer = rememberTextMeasurer(0) val painterCache: Map, Painter> = features.features.flatMap { if (it is FeatureGroup) it.features else listOf(it) diff --git a/settings.gradle.kts b/settings.gradle.kts index 29d9fec..8307daa 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -59,6 +59,7 @@ include( ":demo:maps", ":demo:scheme", ":demo:polygon-editor", - ":demo:trajectory-playground" + ":demo:trajectory-playground", + ":demo:maps-js" ) diff --git a/trajectory-kt/build.gradle.kts b/trajectory-kt/build.gradle.kts index 0783429..45512f0 100644 --- a/trajectory-kt/build.gradle.kts +++ b/trajectory-kt/build.gradle.kts @@ -10,7 +10,7 @@ val kmathVersion: String by rootProject.extra kscience{ jvm() js() -// native() + native() useContextReceivers() useSerialization()