Compare commits
49 Commits
image-pain
...
state_enca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
516d8d0233 | ||
|
|
6b6fa45596 | ||
|
|
13b44a8091 | ||
|
|
190877c10e | ||
|
|
ccf61951dc | ||
|
|
954cc26bfd | ||
|
|
22cbeddaf0 | ||
|
|
3e2c8d2db2 | ||
| 14acd88358 | |||
| 4c3aefcfae | |||
| 8867388e85 | |||
| 2c87ba7638 | |||
| cdee88573d | |||
| 09dfdcc84a | |||
| 14b3142f43 | |||
| 7ada7f85f2 | |||
| c7d1797617 | |||
| 2fdec494fb | |||
| 5e548fcc65 | |||
| 307e42eac2 | |||
| 52d0d959de | |||
| f218853544 | |||
| 2e8de0b183 | |||
| a8b304bd20 | |||
| 7e0acaeca9 | |||
| f49ff34a18 | |||
| 9f6386a8c2 | |||
|
|
3787e990ae | ||
| 7cfc76f2c7 | |||
| 7a243780d1 | |||
|
|
6b7f0d1015 | ||
|
|
ad058e14b5 | ||
|
|
dd53662038 | ||
|
|
b520d2f93a | ||
| 1ebde3a7a6 | |||
|
|
96844cd526 | ||
|
|
e56ed96fdb | ||
|
|
5d3db81c4f | ||
|
|
fc0f223766 | ||
| ec2dcdccaa | |||
|
|
0694cd6a07 | ||
|
|
416328e320 | ||
| 5984de70b4 | |||
|
|
7dd59dbf2a | ||
| d720470ea2 | |||
| f92ccb4838 | |||
| d3809aca8d | |||
| 9392c0f991 | |||
| 1541fb4f39 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
build/
|
||||
.gradle/
|
||||
.idea/
|
||||
*.iml
|
||||
/*.iml
|
||||
|
||||
mapCache/
|
||||
45
.space.kts
Normal file
45
.space.kts
Normal file
@@ -0,0 +1,45 @@
|
||||
import kotlin.io.path.readText
|
||||
|
||||
job("Build") {
|
||||
gradlew("openjdk:11", "build")
|
||||
}
|
||||
|
||||
job("Publish") {
|
||||
startOn {
|
||||
gitPush { enabled = false }
|
||||
}
|
||||
container("openjdk:11") {
|
||||
env["SPACE_USER"] = Secrets("space_user")
|
||||
env["SPACE_TOKEN"] = Secrets("space_token")
|
||||
kotlinScript { api ->
|
||||
|
||||
val spaceUser = System.getenv("SPACE_USER")
|
||||
val spaceToken = System.getenv("SPACE_TOKEN")
|
||||
|
||||
// write version to the build directory
|
||||
api.gradlew("version")
|
||||
|
||||
//read version from build file
|
||||
val version = java.nio.file.Path.of("build/project-version.txt").readText()
|
||||
|
||||
val revisionSuffix = if (version.endsWith("SNAPSHOT")) {
|
||||
"-" + api.gitRevision().take(7)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
api.space().projects.automation.deployments.start(
|
||||
project = api.projectIdentifier(),
|
||||
targetIdentifier = TargetIdentifier.Key("maps-kt"),
|
||||
version = version+revisionSuffix,
|
||||
// automatically update deployment status based on a status of a job
|
||||
syncWithAutomationJob = true
|
||||
)
|
||||
api.gradlew(
|
||||
"publishAllPublicationsToSpaceRepository",
|
||||
"-Ppublishing.space.user=\"$spaceUser\"",
|
||||
"-Ppublishing.space.token=\"$spaceToken\"",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
201
LICENSE
Normal file
201
LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
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.
|
||||
13
README.md
Normal file
13
README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
This repository is a work-in-progress implementation of Map-with-markers component for Compose-Multiplatform
|
||||
|
||||
## [maps-kt-core](maps-kt-core)
|
||||
A multiplatform coordinates representation and conversion.
|
||||
|
||||
## [maps-kt-compose](maps-kt-compose)
|
||||
A compose multiplatform (currently desktop only, contributions of android target are welcome) implementation of a map component, features and builder.
|
||||
|
||||
## [maps-kt-scheme](maps-kt-scheme)
|
||||
An alternative component used for the same functionality on 2D schemes. Not all features from maps could be ported because it requires some code duplication (ideas for common API are welcome).
|
||||
|
||||
## [demo](demo)
|
||||
Demonstration projects for different features
|
||||
126
build.gradle.kts
126
build.gradle.kts
@@ -1,58 +1,90 @@
|
||||
import org.jetbrains.compose.compose
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
id("org.jetbrains.compose")
|
||||
`maven-publish`
|
||||
}
|
||||
|
||||
group = "center.sciptog"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||
base
|
||||
}
|
||||
|
||||
val ktorVersion by extra("2.0.3")
|
||||
|
||||
kotlin {
|
||||
jvm {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = "11"
|
||||
}
|
||||
withJava()
|
||||
}
|
||||
sourceSets {
|
||||
commonMain{
|
||||
dependencies{
|
||||
api(compose.runtime)
|
||||
api(compose.foundation)
|
||||
api(compose.material)
|
||||
api("io.ktor:ktor-client-core:$ktorVersion")
|
||||
implementation("io.ktor:ktor-client-cio:$ktorVersion")
|
||||
implementation("io.github.microutils:kotlin-logging:2.1.23")
|
||||
}
|
||||
}
|
||||
val jvmMain by getting {
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation("ch.qos.logback:logback-classic:1.2.11")
|
||||
}
|
||||
}
|
||||
val jvmTest by getting
|
||||
allprojects {
|
||||
group = "center.sciprog"
|
||||
version = "0.1.0-SNAPSHOT"
|
||||
}
|
||||
|
||||
tasks.create("version") {
|
||||
group = "publishing"
|
||||
val versionFile = project.buildDir.resolve("project-version.txt")
|
||||
outputs.file(versionFile)
|
||||
doLast {
|
||||
versionFile.createNewFile()
|
||||
versionFile.writeText(project.version.toString())
|
||||
println(project.version)
|
||||
}
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "MainKt"
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "maps-kt-compose"
|
||||
packageVersion = "1.0.0"
|
||||
subprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||
}
|
||||
|
||||
plugins.withId("maven-publish") {
|
||||
|
||||
configure<PublishingExtension> {
|
||||
val vcs = "https://github.com/mipt-npm/maps-kt"
|
||||
|
||||
// Process each publication we have in this project
|
||||
publications {
|
||||
withType<MavenPublication> {
|
||||
pom {
|
||||
name.set(project.name)
|
||||
description.set(project.description)
|
||||
url.set(vcs)
|
||||
|
||||
licenses {
|
||||
license {
|
||||
name.set("The Apache Software License, Version 2.0")
|
||||
url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
|
||||
distribution.set("repo")
|
||||
}
|
||||
}
|
||||
|
||||
developers {
|
||||
developer {
|
||||
id.set("SPC")
|
||||
name.set("Scientific programming centre")
|
||||
organization.set("MIPT")
|
||||
organizationUrl.set("https://sciprog.center/")
|
||||
}
|
||||
}
|
||||
|
||||
scm {
|
||||
url.set(vcs)
|
||||
tag.set(project.version.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val spaceRepo = "https://maven.pkg.jetbrains.space/mipt-npm/p/sci/maven"
|
||||
val spaceUser: String? = project.findProperty("publishing.space.user") as? String
|
||||
val spaceToken: String? = project.findProperty("publishing.space.token") as? String
|
||||
|
||||
if (spaceUser != null && spaceToken != null) {
|
||||
project.logger.info("Adding mipt-npm Space publishing to project [${project.name}]")
|
||||
|
||||
repositories.maven {
|
||||
name = "space"
|
||||
url = uri(spaceRepo)
|
||||
|
||||
credentials {
|
||||
username = spaceUser
|
||||
password = spaceToken
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
40
demo/maps/build.gradle.kts
Normal file
40
demo/maps/build.gradle.kts
Normal file
@@ -0,0 +1,40 @@
|
||||
import org.jetbrains.compose.compose
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
id("org.jetbrains.compose")
|
||||
}
|
||||
|
||||
val ktorVersion: String by rootProject.extra
|
||||
|
||||
kotlin {
|
||||
jvm {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = "11"
|
||||
}
|
||||
withJava()
|
||||
}
|
||||
sourceSets {
|
||||
val jvmMain by getting {
|
||||
dependencies {
|
||||
implementation(projects.mapsKtCompose)
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation("io.ktor:ktor-client-cio:$ktorVersion")
|
||||
implementation("ch.qos.logback:logback-classic:1.2.11")
|
||||
}
|
||||
}
|
||||
val jvmTest by getting
|
||||
}
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "MainKt"
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "maps-compose-demo"
|
||||
packageVersion = "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
demo/maps/src/jvmMain/kotlin/AngleConversion.kt
Normal file
6
demo/maps/src/jvmMain/kotlin/AngleConversion.kt
Normal file
@@ -0,0 +1,6 @@
|
||||
import kotlin.math.PI
|
||||
|
||||
fun Double.toDegrees() = this * 180 / PI
|
||||
|
||||
fun Double.toRadians() = this * PI / 180
|
||||
|
||||
148
demo/maps/src/jvmMain/kotlin/Main.kt
Normal file
148
demo/maps/src/jvmMain/kotlin/Main.kt
Normal file
@@ -0,0 +1,148 @@
|
||||
// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PointMode
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import center.sciprog.maps.compose.*
|
||||
import center.sciprog.maps.coordinates.*
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.nio.file.Path
|
||||
import kotlin.math.PI
|
||||
import kotlin.random.Random
|
||||
|
||||
private fun GeodeticMapCoordinates.toShortString(): String =
|
||||
"${(latitude * 180.0 / PI).toString().take(6)}:${(longitude * 180.0 / PI).toString().take(6)}"
|
||||
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
MaterialTheme {
|
||||
//create a view point
|
||||
val viewPoint = remember {
|
||||
MapViewPoint(
|
||||
GeodeticMapCoordinates.ofDegrees(55.7558, 37.6173),
|
||||
8.0
|
||||
)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val mapTileProvider = remember {
|
||||
OpenStreetMapTileProvider(
|
||||
client = HttpClient(CIO),
|
||||
cacheDirectory = Path.of("mapCache")
|
||||
)
|
||||
}
|
||||
|
||||
var centerCoordinates by remember { mutableStateOf<GeodeticMapCoordinates?>(null) }
|
||||
|
||||
|
||||
val pointOne = 55.568548 to 37.568604
|
||||
var pointTwo by remember { mutableStateOf(55.929444 to 37.518434) }
|
||||
val pointThree = 60.929444 to 37.518434
|
||||
|
||||
val state = MapViewState(
|
||||
mapTileProvider = mapTileProvider,
|
||||
initialViewPoint = { viewPoint },
|
||||
) {
|
||||
image(pointOne, Icons.Filled.Home)
|
||||
|
||||
points(
|
||||
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
|
||||
),
|
||||
pointMode = PointMode.Polygon
|
||||
)
|
||||
|
||||
//remember feature Id
|
||||
val circleId: FeatureId = circle(
|
||||
centerCoordinates = pointTwo,
|
||||
)
|
||||
|
||||
draw(
|
||||
position = pointThree,
|
||||
getBoundingBox = {
|
||||
GmcBox.withCenter(
|
||||
center = GeodeticMapCoordinates.ofDegrees(
|
||||
pointThree.first,
|
||||
pointThree.second
|
||||
),
|
||||
height = Distance(0.001),
|
||||
width = Distance(0.001)
|
||||
)
|
||||
}
|
||||
) {
|
||||
drawLine(start = Offset(-10f, -10f), end = Offset(10f, 10f), color = Color.Red)
|
||||
drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red)
|
||||
}
|
||||
|
||||
arc(pointOne, Distance(10.0), 0f, PI)
|
||||
|
||||
line(pointOne, pointTwo, id = "line")
|
||||
text(pointOne, "Home", font = { size = 32f })
|
||||
|
||||
centerCoordinates?.let {
|
||||
group(id = "center") {
|
||||
circle(center = it, color = Color.Blue, size = 1f)
|
||||
text(position = it, it.toShortString(), color = Color.Blue)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(200)
|
||||
//Overwrite a feature with new color
|
||||
circle(
|
||||
pointTwo,
|
||||
id = circleId,
|
||||
color = Color(Random.nextFloat(), Random.nextFloat(), Random.nextFloat())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val config = MapViewConfig(
|
||||
onViewChange = { centerCoordinates = focus },
|
||||
onDrag = { start, end ->
|
||||
val markerRadius = 5f
|
||||
val startPosition = with(state) { start.focus.toOffset(this@MapViewConfig) }
|
||||
val markerLocation = with(state) {
|
||||
GeodeticMapCoordinates.ofDegrees(pointTwo.first, pointTwo.second).toOffset(this@MapViewConfig)
|
||||
}
|
||||
if (startPosition.x in (markerLocation.x - markerRadius)..(markerLocation.x + markerRadius) &&
|
||||
startPosition.y in (markerLocation.y - markerRadius)..(markerLocation.y + markerRadius)
|
||||
) {
|
||||
pointTwo = pointTwo.first + (end.focus.latitude - start.focus.latitude).toDegrees() to
|
||||
pointTwo.second + (end.focus.longitude - start.focus.longitude).toDegrees()
|
||||
false// returning false, because when we are dragging circle we don't want to drag map
|
||||
} else true
|
||||
}
|
||||
)
|
||||
|
||||
MapView(
|
||||
mapViewState = state,
|
||||
mapViewConfig = config
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun main() = application {
|
||||
Window(onCloseRequest = ::exitApplication) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
39
demo/scheme/build.gradle.kts
Normal file
39
demo/scheme/build.gradle.kts
Normal file
@@ -0,0 +1,39 @@
|
||||
import org.jetbrains.compose.compose
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
id("org.jetbrains.compose")
|
||||
}
|
||||
|
||||
val ktorVersion: String by rootProject.extra
|
||||
|
||||
kotlin {
|
||||
jvm {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = "11"
|
||||
}
|
||||
withJava()
|
||||
}
|
||||
sourceSets {
|
||||
val jvmMain by getting {
|
||||
dependencies {
|
||||
implementation(projects.mapsKtScheme)
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation("ch.qos.logback:logback-classic:1.2.11")
|
||||
}
|
||||
}
|
||||
val jvmTest by getting
|
||||
}
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "MainKt"
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "scheme-compose-demo"
|
||||
packageVersion = "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
47
demo/scheme/src/jvmMain/kotlin/Main.kt
Normal file
47
demo/scheme/src/jvmMain/kotlin/Main.kt
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import center.sciprog.maps.scheme.*
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
MaterialTheme {
|
||||
//create a view point
|
||||
val viewPoint = remember {
|
||||
SchemeViewPoint(
|
||||
SchemeCoordinates(0f, 0f),
|
||||
1f
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
SchemeView(
|
||||
viewPoint,
|
||||
config = SchemeViewConfig(
|
||||
inferViewBoxFromFeatures = true,
|
||||
onClick = {
|
||||
println("${focus.x}, ${focus.y}")
|
||||
}
|
||||
)
|
||||
) {
|
||||
background(painterResource("middle-earth.jpg"))
|
||||
circle(410.52737 to 868.7676, color = Color.Blue)
|
||||
text(410.52737 to 868.7676,"Shire", color = Color.Blue)
|
||||
circle(1132.0881 to 394.99127, color = Color.Red)
|
||||
text(1132.0881 to 394.99127, "Ordruin",color = Color.Red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun main() = application {
|
||||
Window(onCloseRequest = ::exitApplication) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
kotlin.code.style=official
|
||||
kotlin.version=1.6.10
|
||||
compose.version=1.1.1
|
||||
agp.version=4.2.2
|
||||
compose.version=1.1.1
|
||||
android.useAndroidX=true
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,5 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
30
maps-kt-compose/build.gradle.kts
Normal file
30
maps-kt-compose/build.gradle.kts
Normal file
@@ -0,0 +1,30 @@
|
||||
import org.jetbrains.compose.compose
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
id("org.jetbrains.compose")
|
||||
`maven-publish`
|
||||
}
|
||||
|
||||
val ktorVersion: String by rootProject.extra
|
||||
|
||||
kotlin {
|
||||
explicitApi = org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode.Warning
|
||||
jvm {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = "11"
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api(projects.mapsKtCore)
|
||||
api(compose.foundation)
|
||||
api("io.ktor:ktor-client-core:$ktorVersion")
|
||||
api("io.github.microutils:kotlin-logging:2.1.23")
|
||||
}
|
||||
}
|
||||
val jvmMain by getting
|
||||
val jvmTest by getting
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import kotlin.jvm.Synchronized
|
||||
|
||||
|
||||
internal class LruCache<K, V>(
|
||||
private var capacity: Int,
|
||||
) {
|
||||
private val cache = linkedMapOf<K, V>()
|
||||
|
||||
@Synchronized
|
||||
fun put(key: K, value: V){
|
||||
if (cache.size >= capacity) {
|
||||
cache.remove(cache.iterator().next().key)
|
||||
}
|
||||
cache[key] = value
|
||||
}
|
||||
|
||||
operator fun get(key: K): V? {
|
||||
val value = cache[key]
|
||||
if (value != null) {
|
||||
cache.remove(key)
|
||||
cache[key] = value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun remove(key: K) {
|
||||
cache.remove(key)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrPut(key: K, factory: () -> V): V = get(key) ?: factory().also { put(key, it) }
|
||||
|
||||
@Synchronized
|
||||
fun reset(newCapacity: Int? = null) {
|
||||
cache.clear()
|
||||
capacity = newCapacity ?: capacity
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.PointMode
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
|
||||
import center.sciprog.maps.coordinates.GmcBox
|
||||
import center.sciprog.maps.coordinates.wrapAll
|
||||
|
||||
public interface MapFeature {
|
||||
public val zoomRange: IntRange
|
||||
public fun getBoundingBox(zoom: Int): GmcBox?
|
||||
}
|
||||
|
||||
public fun Iterable<MapFeature>.computeBoundingBox(zoom: Int): GmcBox? =
|
||||
mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
|
||||
|
||||
internal fun Pair<Double, Double>.toCoordinates() = GeodeticMapCoordinates.ofDegrees(first, second)
|
||||
|
||||
internal val defaultZoomRange = 1..18
|
||||
|
||||
/**
|
||||
* A feature that decides what to show depending on the zoom value (it could change size of shape)
|
||||
*/
|
||||
public class MapFeatureSelector(
|
||||
public val selector: (zoom: Int) -> MapFeature,
|
||||
) : MapFeature {
|
||||
override val zoomRange: IntRange get() = defaultZoomRange
|
||||
|
||||
override fun getBoundingBox(zoom: Int): GmcBox? = selector(zoom).getBoundingBox(zoom)
|
||||
}
|
||||
|
||||
public class MapDrawFeature(
|
||||
public val position: GeodeticMapCoordinates,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
private val computeBoundingBox: (zoom: Int) -> GmcBox,
|
||||
public val drawFeature: DrawScope.() -> Unit,
|
||||
) : MapFeature {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox = computeBoundingBox(zoom)
|
||||
}
|
||||
|
||||
public class MapPointsFeature(
|
||||
public val points: List<GeodeticMapCoordinates>,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
public val stroke: Float = 2f,
|
||||
public val color: Color = Color.Red,
|
||||
public val pointMode: PointMode = PointMode.Points
|
||||
) : MapFeature {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox {
|
||||
return GmcBox(points.first(), points.last())
|
||||
}
|
||||
}
|
||||
|
||||
public class MapCircleFeature(
|
||||
public val center: GeodeticMapCoordinates,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
public val size: Float = 5f,
|
||||
public val color: Color = Color.Red,
|
||||
) : MapFeature {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(center, center)
|
||||
}
|
||||
|
||||
public class MapRectangleFeature(
|
||||
public val center: GeodeticMapCoordinates,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
public val size: DpSize = DpSize(5.dp, 5.dp),
|
||||
public val color: Color = Color.Red,
|
||||
) : MapFeature {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(center, center)
|
||||
}
|
||||
|
||||
public class MapLineFeature(
|
||||
public val a: GeodeticMapCoordinates,
|
||||
public val b: GeodeticMapCoordinates,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
public val color: Color = Color.Red,
|
||||
) : MapFeature {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(a, b)
|
||||
}
|
||||
|
||||
public class MapArcFeature(
|
||||
public val oval: GmcBox,
|
||||
public val startAngle: Float,
|
||||
public val endAngle: Float,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
public val color: Color = Color.Red,
|
||||
) : MapFeature {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox = oval
|
||||
}
|
||||
|
||||
public class MapBitmapImageFeature(
|
||||
public val position: GeodeticMapCoordinates,
|
||||
public val image: ImageBitmap,
|
||||
public val size: IntSize = IntSize(15, 15),
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
) : MapFeature {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
|
||||
}
|
||||
|
||||
public class MapVectorImageFeature(
|
||||
public val position: GeodeticMapCoordinates,
|
||||
public val painter: Painter,
|
||||
public val size: DpSize,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
) : MapFeature {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun MapVectorImageFeature(
|
||||
position: GeodeticMapCoordinates,
|
||||
image: ImageVector,
|
||||
size: DpSize = DpSize(20.dp, 20.dp),
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
): MapVectorImageFeature = MapVectorImageFeature(position, rememberVectorPainter(image), size, zoomRange)
|
||||
|
||||
/**
|
||||
* A group of other features
|
||||
*/
|
||||
public class MapFeatureGroup(
|
||||
public val children: Map<FeatureId, MapFeature>,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
) : MapFeature {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox? = children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PointMode
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import center.sciprog.maps.coordinates.Distance
|
||||
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
|
||||
import center.sciprog.maps.coordinates.GmcBox
|
||||
|
||||
public typealias FeatureId = String
|
||||
|
||||
public interface MapFeatureBuilder {
|
||||
public fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId
|
||||
|
||||
public fun build(): SnapshotStateMap<FeatureId, MapFeature>
|
||||
}
|
||||
|
||||
internal class MapFeatureBuilderImpl(initialFeatures: Map<FeatureId, MapFeature>) : MapFeatureBuilder {
|
||||
|
||||
private val content: SnapshotStateMap<FeatureId, MapFeature> = mutableStateMapOf<FeatureId, MapFeature>().apply {
|
||||
putAll(initialFeatures)
|
||||
}
|
||||
|
||||
private fun generateID(feature: MapFeature): FeatureId = "@feature[${feature.hashCode().toUInt()}]"
|
||||
|
||||
override fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId {
|
||||
val safeId = id ?: generateID(feature)
|
||||
content[id ?: generateID(feature)] = feature
|
||||
return safeId
|
||||
}
|
||||
|
||||
override fun build(): SnapshotStateMap<FeatureId, MapFeature> = content
|
||||
}
|
||||
|
||||
public fun MapFeatureBuilder.circle(
|
||||
center: GeodeticMapCoordinates,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
size: Float = 5f,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
): FeatureId = addFeature(
|
||||
id, MapCircleFeature(center, zoomRange, size, color)
|
||||
)
|
||||
|
||||
public fun MapFeatureBuilder.circle(
|
||||
centerCoordinates: Pair<Double, Double>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
size: Float = 5f,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
): FeatureId = addFeature(
|
||||
id, MapCircleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color)
|
||||
)
|
||||
|
||||
public fun MapFeatureBuilder.rectangle(
|
||||
centerCoordinates: Pair<Double, Double>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
size: DpSize = DpSize(5.dp, 5.dp),
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
): FeatureId = addFeature(
|
||||
id, MapRectangleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color)
|
||||
)
|
||||
|
||||
public fun MapFeatureBuilder.draw(
|
||||
position: Pair<Double, Double>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
id: FeatureId? = null,
|
||||
getBoundingBox: (Int) -> GmcBox,
|
||||
drawFeature: DrawScope.() -> Unit,
|
||||
): FeatureId = addFeature(id, MapDrawFeature(position.toCoordinates(), zoomRange, getBoundingBox, drawFeature))
|
||||
|
||||
public fun MapFeatureBuilder.line(
|
||||
aCoordinates: Pair<Double, Double>,
|
||||
bCoordinates: Pair<Double, Double>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
): FeatureId = addFeature(
|
||||
id,
|
||||
MapLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color)
|
||||
)
|
||||
|
||||
public fun MapFeatureBuilder.arc(
|
||||
oval: GmcBox,
|
||||
startAngle: Number,
|
||||
endAngle: Number,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
): FeatureId = addFeature(
|
||||
id,
|
||||
MapArcFeature(oval, startAngle.toFloat(), endAngle.toFloat(), zoomRange, color)
|
||||
)
|
||||
|
||||
public fun MapFeatureBuilder.arc(
|
||||
center: Pair<Double, Double>,
|
||||
radius: Distance,
|
||||
startAngle: Number,
|
||||
endAngle: Number,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
): FeatureId = addFeature(
|
||||
id,
|
||||
MapArcFeature(
|
||||
GmcBox.withCenter(center.toCoordinates(), radius, radius),
|
||||
startAngle.toFloat(),
|
||||
endAngle.toFloat(),
|
||||
zoomRange,
|
||||
color
|
||||
)
|
||||
)
|
||||
|
||||
public fun MapFeatureBuilder.points(
|
||||
points: List<Pair<Double, Double>>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
stroke: Float = 2f,
|
||||
color: Color = Color.Red,
|
||||
pointMode: PointMode = PointMode.Points,
|
||||
id: FeatureId? = null
|
||||
): FeatureId = addFeature(id, MapPointsFeature(points.map { it.toCoordinates() }, zoomRange, stroke, color, pointMode))
|
||||
|
||||
@Composable
|
||||
public fun MapFeatureBuilder.image(
|
||||
position: Pair<Double, Double>,
|
||||
image: ImageVector,
|
||||
size: DpSize = DpSize(20.dp, 20.dp),
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
id: FeatureId? = null,
|
||||
): FeatureId = addFeature(id, MapVectorImageFeature(position.toCoordinates(), image, size, zoomRange))
|
||||
|
||||
public fun MapFeatureBuilder.group(
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
id: FeatureId? = null,
|
||||
builder: MapFeatureBuilder.() -> Unit,
|
||||
): FeatureId {
|
||||
val map = MapFeatureBuilderImpl(emptyMap()).apply(builder).build()
|
||||
val feature = MapFeatureGroup(map, zoomRange)
|
||||
return addFeature(id, feature)
|
||||
}
|
||||
|
||||
public fun MapFeatureBuilder.featureSelector(
|
||||
id: FeatureId? = null,
|
||||
onSelect: MapFeatureBuilder.(zoom: Int) -> MapFeature
|
||||
): FeatureId = addFeature(
|
||||
id = id,
|
||||
feature = MapFeatureSelector(
|
||||
selector = { onSelect(this, it) }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.NativeCanvas
|
||||
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
|
||||
import center.sciprog.maps.coordinates.GmcBox
|
||||
|
||||
public expect class Font constructor() {
|
||||
public var size: Float
|
||||
}
|
||||
|
||||
public expect fun NativeCanvas.drawString(text: String, x: Float, y: Float, font: Font, color: Color)
|
||||
|
||||
public class MapTextFeature(
|
||||
public val position: GeodeticMapCoordinates,
|
||||
public val text: String,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
public val color: Color,
|
||||
public val fontConfig: Font.() -> Unit,
|
||||
) : MapFeature {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
|
||||
}
|
||||
|
||||
public fun MapFeatureBuilder.text(
|
||||
position: GeodeticMapCoordinates,
|
||||
text: String,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
color: Color = Color.Red,
|
||||
font: Font.() -> Unit = { size = 16f },
|
||||
id: FeatureId? = null,
|
||||
): FeatureId = addFeature(id, MapTextFeature(position, text, zoomRange, color, font))
|
||||
|
||||
public fun MapFeatureBuilder.text(
|
||||
position: Pair<Double, Double>,
|
||||
text: String,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
color: Color = Color.Red,
|
||||
font: Font.() -> Unit = { size = 16f },
|
||||
id: FeatureId? = null,
|
||||
): FeatureId = addFeature(id, MapTextFeature(position.toCoordinates(), text, zoomRange, color, font))
|
||||
@@ -0,0 +1,31 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlin.math.floor
|
||||
|
||||
public data class TileId(
|
||||
val zoom: Int,
|
||||
val i: Int,
|
||||
val j: Int,
|
||||
)
|
||||
|
||||
public data class MapTile(
|
||||
val id: TileId,
|
||||
val image: ImageBitmap,
|
||||
)
|
||||
|
||||
public interface MapTileProvider {
|
||||
public fun CoroutineScope.loadTileAsync(tileId: TileId): Deferred<MapTile>
|
||||
|
||||
public val tileSize: Int get() = DEFAULT_TILE_SIZE
|
||||
|
||||
public fun toIndex(d: Double): Int = floor(d / tileSize).toInt()
|
||||
|
||||
public fun toCoordinate(i: Int): Double = (i * tileSize).toDouble()
|
||||
|
||||
public companion object {
|
||||
public const val DEFAULT_TILE_SIZE: Int = 256
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import center.sciprog.maps.coordinates.*
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
//TODO consider replacing by modifier
|
||||
/**
|
||||
* @param onDrag - returns true if you want to drag a map and false, if you want to make map stationary.
|
||||
* start - is a point where drag begins, end is a point where drag ends
|
||||
*/
|
||||
public data class MapViewConfig(
|
||||
val zoomSpeed: Double = 1.0 / 3.0,
|
||||
val onClick: MapViewPoint.() -> Unit = {},
|
||||
val onDrag: Density.(start: MapViewPoint, end: MapViewPoint) -> Boolean = { _, _ -> true },
|
||||
val onViewChange: MapViewPoint.() -> Unit = {},
|
||||
val onSelect: (GmcBox) -> Unit = {},
|
||||
val zoomOnSelect: Boolean = true,
|
||||
val resetViewPoint: Boolean = false
|
||||
)
|
||||
|
||||
@Composable
|
||||
public expect fun MapView(
|
||||
modifier: Modifier = Modifier,
|
||||
mapViewState: MapViewState,
|
||||
mapViewConfig: MapViewConfig,
|
||||
)
|
||||
|
||||
@Composable
|
||||
public fun MapView(
|
||||
mapTileProvider: MapTileProvider,
|
||||
initialViewPoint: MapViewPoint,
|
||||
features: Map<FeatureId, MapFeature> = emptyMap(),
|
||||
config: MapViewConfig = MapViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
|
||||
) {
|
||||
val featuresBuilder = MapFeatureBuilderImpl(features)
|
||||
featuresBuilder.buildFeatures()
|
||||
MapView(
|
||||
mapViewState = MapViewState(
|
||||
mapTileProvider = mapTileProvider,
|
||||
initialViewPoint = { initialViewPoint },
|
||||
features = featuresBuilder.build(),
|
||||
),
|
||||
mapViewConfig = config,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
internal fun GmcBox.computeViewPoint(mapTileProvider: MapTileProvider): (canvasSize: DpSize) -> MapViewPoint =
|
||||
{ canvasSize ->
|
||||
val zoom = log2(
|
||||
min(
|
||||
canvasSize.width.value / width,
|
||||
canvasSize.height.value / height
|
||||
) * PI / mapTileProvider.tileSize
|
||||
)
|
||||
MapViewPoint(center, zoom)
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun MapView(
|
||||
mapTileProvider: MapTileProvider,
|
||||
box: GmcBox,
|
||||
features: Map<FeatureId, MapFeature> = emptyMap(),
|
||||
config: MapViewConfig = MapViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
|
||||
) {
|
||||
val featuresBuilder = MapFeatureBuilderImpl(features)
|
||||
featuresBuilder.buildFeatures()
|
||||
MapView(
|
||||
mapViewState = MapViewState(
|
||||
mapTileProvider = mapTileProvider,
|
||||
features = featuresBuilder.build(),
|
||||
initialViewPoint = box.computeViewPoint(mapTileProvider),
|
||||
),
|
||||
modifier = modifier,
|
||||
mapViewConfig = config,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import center.sciprog.maps.coordinates.*
|
||||
import mu.KotlinLogging
|
||||
import kotlin.math.*
|
||||
|
||||
@Composable
|
||||
public fun MapViewState(
|
||||
initialViewPoint: (canvasSize: DpSize) -> MapViewPoint,
|
||||
mapTileProvider: MapTileProvider,
|
||||
features: Map<FeatureId, MapFeature> = emptyMap(),
|
||||
inferViewBoxFromFeatures: Boolean = false,
|
||||
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
|
||||
): MapViewState {
|
||||
val featuresBuilder = MapFeatureBuilderImpl(features)
|
||||
featuresBuilder.buildFeatures()
|
||||
return MapViewState(
|
||||
initialViewPoint = initialViewPoint,
|
||||
mapTileProvider = mapTileProvider,
|
||||
features = featuresBuilder.build(),
|
||||
inferViewBoxFromFeatures = inferViewBoxFromFeatures
|
||||
)
|
||||
}
|
||||
|
||||
public class MapViewState(
|
||||
public val initialViewPoint: (canvasSize: DpSize) -> MapViewPoint,
|
||||
public val mapTileProvider: MapTileProvider,
|
||||
public val features: Map<FeatureId, MapFeature> = emptyMap(),
|
||||
inferViewBoxFromFeatures: Boolean = false,
|
||||
) {
|
||||
public var canvasSize: DpSize by mutableStateOf(DpSize(512.dp, 512.dp))
|
||||
public var viewPointInternal: MapViewPoint? by mutableStateOf(null)
|
||||
public val viewPoint: MapViewPoint by derivedStateOf {
|
||||
viewPointInternal ?: if (inferViewBoxFromFeatures) {
|
||||
features.values.computeBoundingBox(1)?.let { box ->
|
||||
val zoom = log2(
|
||||
min(
|
||||
canvasSize.width.value / box.width,
|
||||
canvasSize.height.value / box.height
|
||||
) * PI / mapTileProvider.tileSize
|
||||
)
|
||||
MapViewPoint(box.center, zoom)
|
||||
} ?: initialViewPoint(canvasSize)
|
||||
} else {
|
||||
initialViewPoint(canvasSize)
|
||||
}
|
||||
}
|
||||
public val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() }
|
||||
|
||||
public val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) }
|
||||
|
||||
public val mapTiles: SnapshotStateList<MapTile> = mutableStateListOf()
|
||||
|
||||
public val centerCoordinates: WebMercatorCoordinates by derivedStateOf {
|
||||
WebMercatorProjection.toMercator(
|
||||
viewPoint.focus,
|
||||
zoom
|
||||
)
|
||||
}
|
||||
|
||||
public fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates(
|
||||
zoom,
|
||||
(x - canvasSize.width / 2).value / tileScale + centerCoordinates.x,
|
||||
(y - canvasSize.height / 2).value / tileScale + centerCoordinates.y,
|
||||
)
|
||||
|
||||
/*
|
||||
* Convert screen independent offset to GMC, adjusting for fractional zoom
|
||||
*/
|
||||
public fun DpOffset.toGeodetic(): GeodeticMapCoordinates =
|
||||
with(this@MapViewState) { WebMercatorProjection.toGeodetic(toMercator()) }
|
||||
|
||||
// Selection rectangle. If null - no selection
|
||||
public var selectRect: Rect? by mutableStateOf(null)
|
||||
|
||||
public fun WebMercatorCoordinates.toOffset(density: Density): Offset =
|
||||
with(density) {
|
||||
with(this@MapViewState) {
|
||||
Offset(
|
||||
(canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()).toPx(),
|
||||
(canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()).toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//Convert GMC to offset in pixels (not DP), adjusting for zoom
|
||||
public fun GeodeticMapCoordinates.toOffset(density: Density): Offset =
|
||||
WebMercatorProjection.toMercator(this, zoom).toOffset(density)
|
||||
|
||||
private val logger = KotlinLogging.logger("MapViewState")
|
||||
|
||||
public fun DrawScope.drawFeature(zoom: Int, feature: MapFeature) {
|
||||
when (feature) {
|
||||
is MapFeatureSelector -> drawFeature(zoom, feature.selector(zoom))
|
||||
is MapCircleFeature -> drawCircle(
|
||||
feature.color,
|
||||
feature.size,
|
||||
center = feature.center.toOffset(this@drawFeature)
|
||||
)
|
||||
is MapRectangleFeature -> drawRect(
|
||||
feature.color,
|
||||
topLeft = feature.center.toOffset(this@drawFeature) - Offset(
|
||||
feature.size.width.toPx() / 2,
|
||||
feature.size.height.toPx() / 2
|
||||
),
|
||||
size = feature.size.toSize()
|
||||
)
|
||||
is MapLineFeature -> drawLine(
|
||||
feature.color,
|
||||
feature.a.toOffset(this@drawFeature),
|
||||
feature.b.toOffset(this@drawFeature)
|
||||
)
|
||||
is MapArcFeature -> {
|
||||
val topLeft = feature.oval.topLeft.toOffset(this@drawFeature)
|
||||
val bottomRight = feature.oval.bottomRight.toOffset(this@drawFeature)
|
||||
|
||||
val path = Path().apply {
|
||||
addArcRad(Rect(topLeft, bottomRight), feature.startAngle, feature.endAngle - feature.startAngle)
|
||||
}
|
||||
|
||||
drawPath(path, color = feature.color, style = Stroke())
|
||||
|
||||
}
|
||||
is MapBitmapImageFeature -> drawImage(
|
||||
image = feature.image,
|
||||
topLeft = feature.position.toOffset(this@drawFeature)
|
||||
)
|
||||
is MapVectorImageFeature -> {
|
||||
val offset = feature.position.toOffset(this@drawFeature)
|
||||
val size = feature.size.toSize()
|
||||
translate(offset.x - size.width / 2, offset.y - size.height / 2) {
|
||||
with(feature.painter) {
|
||||
draw(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
is MapTextFeature -> drawIntoCanvas { canvas ->
|
||||
val offset = feature.position.toOffset(this@drawFeature)
|
||||
canvas.nativeCanvas.drawString(
|
||||
feature.text,
|
||||
offset.x + 5,
|
||||
offset.y - 5,
|
||||
Font().apply(feature.fontConfig),
|
||||
feature.color
|
||||
)
|
||||
}
|
||||
is MapDrawFeature -> {
|
||||
val offset = feature.position.toOffset(this)
|
||||
translate(offset.x, offset.y) {
|
||||
feature.drawFeature(this)
|
||||
}
|
||||
}
|
||||
is MapFeatureGroup -> {
|
||||
feature.children.values.forEach {
|
||||
drawFeature(zoom, it)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
logger.error { "Unrecognized feature type: ${feature::class}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.NativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import org.jetbrains.skia.Paint
|
||||
|
||||
public actual typealias Font = org.jetbrains.skia.Font
|
||||
|
||||
public actual fun NativeCanvas.drawString(
|
||||
text: String,
|
||||
x: Float,
|
||||
y: Float,
|
||||
font: Font,
|
||||
color: Color
|
||||
) {
|
||||
drawString(text, x, y, font, color.toPaint())
|
||||
}
|
||||
|
||||
private fun Color.toPaint(): Paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = toArgb()
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
import androidx.compose.foundation.gestures.forEachGesture
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.drawscope.*
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.unit.*
|
||||
import center.sciprog.maps.coordinates.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import mu.KotlinLogging
|
||||
import kotlin.math.*
|
||||
|
||||
|
||||
|
||||
|
||||
private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last)
|
||||
|
||||
internal fun MapViewPoint.move(deltaX: Double, deltaY: Double): MapViewPoint {
|
||||
val newCoordinates = GeodeticMapCoordinates.ofRadians(
|
||||
(focus.latitude + deltaY / scaleFactor).coerceIn(
|
||||
-MercatorProjection.MAXIMUM_LATITUDE,
|
||||
MercatorProjection.MAXIMUM_LATITUDE
|
||||
),
|
||||
focus.longitude + deltaX / scaleFactor
|
||||
)
|
||||
return MapViewPoint(newCoordinates, zoom)
|
||||
}
|
||||
|
||||
private val logger = KotlinLogging.logger("MapView")
|
||||
|
||||
/**
|
||||
* A component that renders map and provides basic map manipulation capabilities
|
||||
*/
|
||||
|
||||
@Composable
|
||||
public actual fun MapView(
|
||||
modifier: Modifier,
|
||||
mapViewState: MapViewState,
|
||||
mapViewConfig: MapViewConfig,
|
||||
) {
|
||||
with(mapViewState) {
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
val canvasModifier = modifier.pointerInput(Unit) {
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
|
||||
|
||||
val event: PointerEvent = awaitPointerEvent()
|
||||
event.changes.forEach { change ->
|
||||
if (event.buttons.isPrimaryPressed) {
|
||||
//Evaluating selection frame
|
||||
if (event.keyboardModifiers.isShiftPressed) {
|
||||
selectRect = Rect(change.position, change.position)
|
||||
drag(change.id) { dragChange ->
|
||||
selectRect?.let { rect ->
|
||||
val offset = dragChange.position
|
||||
selectRect = Rect(
|
||||
min(offset.x, rect.left),
|
||||
min(offset.y, rect.top),
|
||||
max(offset.x, rect.right),
|
||||
max(offset.y, rect.bottom)
|
||||
)
|
||||
}
|
||||
}
|
||||
selectRect?.let { rect ->
|
||||
//Use selection override if it is defined
|
||||
val gmcBox = GmcBox(
|
||||
rect.topLeft.toDpOffset().toGeodetic(),
|
||||
rect.bottomRight.toDpOffset().toGeodetic()
|
||||
)
|
||||
|
||||
mapViewConfig.onSelect(gmcBox)
|
||||
if (mapViewConfig.zoomOnSelect) {
|
||||
val newViewPoint = gmcBox.computeViewPoint(mapTileProvider).invoke(canvasSize)
|
||||
|
||||
mapViewConfig.onViewChange(newViewPoint)
|
||||
viewPointInternal = newViewPoint
|
||||
}
|
||||
selectRect = null
|
||||
}
|
||||
} else {
|
||||
val dragStart = change.position
|
||||
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
|
||||
mapViewConfig.onClick(
|
||||
MapViewPoint(
|
||||
dpPos.toGeodetic() ,
|
||||
viewPoint.zoom
|
||||
)
|
||||
)
|
||||
drag(change.id) { dragChange ->
|
||||
val dragAmount = dragChange.position - dragChange.previousPosition
|
||||
val dpStart =
|
||||
DpOffset(
|
||||
dragChange.previousPosition.x.toDp(),
|
||||
dragChange.previousPosition.y.toDp()
|
||||
)
|
||||
val dpEnd = DpOffset(dragChange.position.x.toDp(), dragChange.position.y.toDp())
|
||||
if (!mapViewConfig.onDrag(
|
||||
this,
|
||||
MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom),
|
||||
MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom)
|
||||
)
|
||||
) return@drag
|
||||
val newViewPoint = viewPoint.move(
|
||||
-dragAmount.x.toDp().value / tileScale,
|
||||
+dragAmount.y.toDp().value / tileScale
|
||||
)
|
||||
mapViewConfig.onViewChange(newViewPoint)
|
||||
viewPointInternal = newViewPoint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onPointerEvent(PointerEventType.Scroll) {
|
||||
val change = it.changes.first()
|
||||
val (xPos, yPos) = change.position
|
||||
//compute invariant point of translation
|
||||
val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toGeodetic()
|
||||
val newViewPoint = viewPoint.zoom(-change.scrollDelta.y.toDouble() * mapViewConfig.zoomSpeed, invariant)
|
||||
mapViewConfig.onViewChange(newViewPoint)
|
||||
viewPointInternal = newViewPoint
|
||||
}.fillMaxSize()
|
||||
|
||||
|
||||
// Load tiles asynchronously
|
||||
LaunchedEffect(viewPoint, canvasSize) {
|
||||
with(mapTileProvider) {
|
||||
val indexRange = 0 until 2.0.pow(zoom).toInt()
|
||||
|
||||
val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale
|
||||
val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale
|
||||
val horizontalIndices: IntRange = (toIndex(left)..toIndex(right)).intersect(indexRange)
|
||||
|
||||
val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale)
|
||||
val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale)
|
||||
val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange)
|
||||
|
||||
mapTiles.clear()
|
||||
|
||||
for (j in verticalIndices) {
|
||||
for (i in horizontalIndices) {
|
||||
val id = TileId(zoom, i, j)
|
||||
//start all
|
||||
val deferred = loadTileAsync(id)
|
||||
//wait asynchronously for it to finish
|
||||
launch {
|
||||
try {
|
||||
mapTiles += deferred.await()
|
||||
} catch (ex: Exception) {
|
||||
if (ex !is CancellationException) {
|
||||
//displaying the error is maps responsibility
|
||||
logger.error(ex) { "Failed to load tile with id=$id" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Canvas(canvasModifier) {
|
||||
if (mapViewState.canvasSize != size.toDpSize()) {
|
||||
mapViewState.canvasSize = size.toDpSize()
|
||||
logger.debug { "Recalculate canvas. Size: $size" }
|
||||
}
|
||||
clipRect {
|
||||
val tileSize = IntSize(
|
||||
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(),
|
||||
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt()
|
||||
)
|
||||
mapTiles.forEach { (id, image) ->
|
||||
//converting back from tile index to screen offset
|
||||
val offset = IntOffset(
|
||||
(canvasSize.width / 2 + (mapTileProvider.toCoordinate(id.i).dp - centerCoordinates.x.dp) * tileScale.toFloat()).roundToPx(),
|
||||
(canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale.toFloat()).roundToPx()
|
||||
)
|
||||
drawImage(
|
||||
image = image,
|
||||
dstOffset = offset,
|
||||
dstSize = tileSize
|
||||
)
|
||||
}
|
||||
features.values.filter { zoom in it.zoomRange }.forEach { feature ->
|
||||
drawFeature(zoom, feature)
|
||||
}
|
||||
}
|
||||
selectRect?.let { rect ->
|
||||
drawRect(
|
||||
color = Color.Blue,
|
||||
topLeft = rect.topLeft,
|
||||
size = rect.size,
|
||||
alpha = 0.5f,
|
||||
style = Stroke(
|
||||
width = 2f,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.readBytes
|
||||
import io.ktor.utils.io.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import mu.KotlinLogging
|
||||
import org.jetbrains.skia.Image
|
||||
import java.net.URL
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.*
|
||||
|
||||
/**
|
||||
* A [MapTileProvider] based on Open Street Map API. With in-memory and file cache
|
||||
*/
|
||||
public class OpenStreetMapTileProvider(
|
||||
private val client: HttpClient,
|
||||
private val cacheDirectory: Path,
|
||||
parallelism: Int = 1,
|
||||
cacheCapacity: Int = 200,
|
||||
) : MapTileProvider {
|
||||
private val semaphore = Semaphore(parallelism)
|
||||
private val cache = LruCache<TileId, Deferred<ImageBitmap>>(cacheCapacity)
|
||||
|
||||
private fun TileId.osmUrl() = URL("https://tile.openstreetmap.org/${zoom}/${i}/${j}.png")
|
||||
|
||||
private fun TileId.cacheFilePath() = cacheDirectory.resolve("${zoom}/${i}/${j}.png")
|
||||
|
||||
/**
|
||||
* Download and cache the tile image
|
||||
*/
|
||||
private fun CoroutineScope.downloadImageAsync(id: TileId) = async(Dispatchers.IO) {
|
||||
|
||||
id.cacheFilePath()?.let { path ->
|
||||
if (path.exists()) {
|
||||
try {
|
||||
return@async Image.makeFromEncoded(path.readBytes()).toComposeImageBitmap()
|
||||
} catch (ex: Exception) {
|
||||
logger.debug { "Failed to load image from $path" }
|
||||
path.deleteIfExists()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//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" }
|
||||
|
||||
id.cacheFilePath()?.let { path ->
|
||||
logger.debug { "Caching map tile $id to $path" }
|
||||
|
||||
path.parent.createDirectories()
|
||||
path.writeBytes(byteArray)
|
||||
}
|
||||
|
||||
Image.makeFromEncoded(byteArray).toComposeImageBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
override fun CoroutineScope.loadTileAsync(
|
||||
tileId: TileId,
|
||||
): Deferred<MapTile> {
|
||||
|
||||
//start image download
|
||||
val imageDeferred = cache.getOrPut(tileId) {
|
||||
downloadImageAsync(tileId)
|
||||
}
|
||||
|
||||
//collect the result asynchronously
|
||||
return async {
|
||||
val image = try {
|
||||
imageDeferred.await()
|
||||
} catch (ex: Exception) {
|
||||
cache.remove(tileId)
|
||||
if(ex !is CancellationException) {
|
||||
logger.error(ex) { "Failed to load tile image with id=$tileId" }
|
||||
}
|
||||
throw ex
|
||||
}
|
||||
MapTile(tileId, image)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public companion object {
|
||||
private val logger = KotlinLogging.logger("OpenStreetMapCache")
|
||||
}
|
||||
}
|
||||
18
maps-kt-core/build.gradle.kts
Normal file
18
maps-kt-core/build.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
`maven-publish`
|
||||
}
|
||||
|
||||
val ktorVersion: String by rootProject.extra
|
||||
|
||||
kotlin {
|
||||
explicitApi = org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode.Warning
|
||||
jvm {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = "11"
|
||||
}
|
||||
}
|
||||
js(IR) {
|
||||
browser()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package center.sciprog.maps.coordinates
|
||||
|
||||
import kotlin.jvm.JvmInline
|
||||
|
||||
@JvmInline
|
||||
public value class Distance(public val kilometers: Double)
|
||||
|
||||
public operator fun Distance.div(other: Distance): Double = kilometers / other.kilometers
|
||||
|
||||
public operator fun Distance.plus(other: Distance): Distance = Distance(kilometers + other.kilometers)
|
||||
public operator fun Distance.minus(other: Distance): Distance = Distance(kilometers - other.kilometers)
|
||||
|
||||
public operator fun Distance.times(number: Number): Distance = Distance(kilometers * number.toDouble())
|
||||
public operator fun Distance.div(number: Number): Distance = Distance(kilometers / number.toDouble())
|
||||
|
||||
|
||||
public val Distance.meters: Double get() = kilometers * 1000
|
||||
@@ -0,0 +1,14 @@
|
||||
package center.sciprog.maps.coordinates
|
||||
|
||||
public class Ellipsoid(public val equatorRadius: Distance, public val polarRadius: Distance) {
|
||||
public companion object {
|
||||
public val WGS84: Ellipsoid = Ellipsoid(
|
||||
equatorRadius = Distance(6378.137),
|
||||
polarRadius = Distance(6356.7523142)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public val Ellipsoid.f: Double get() = (equatorRadius.kilometers - polarRadius.kilometers) / equatorRadius.kilometers
|
||||
|
||||
public val Ellipsoid.inverseF: Double get() = equatorRadius.kilometers / (equatorRadius.kilometers - polarRadius.kilometers)
|
||||
@@ -1,11 +1,14 @@
|
||||
package centre.sciprog.maps
|
||||
package center.sciprog.maps.coordinates
|
||||
|
||||
import kotlin.math.PI
|
||||
|
||||
/**
|
||||
* Geodetic coordinated
|
||||
*/
|
||||
public class GeodeticMapCoordinates private constructor(public val latitude: Double, public val longitude: Double) {
|
||||
public class GeodeticMapCoordinates private constructor(
|
||||
public val latitude: Double,
|
||||
public val longitude: Double,
|
||||
){
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
@@ -43,7 +46,6 @@ public class GeodeticMapCoordinates private constructor(public val latitude: Dou
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//public interface GeoToScreenConversion {
|
||||
// public fun getScreenX(gmc: GeodeticMapCoordinates): Double
|
||||
// public fun getScreenY(gmc: GeodeticMapCoordinates): Double
|
||||
@@ -0,0 +1,77 @@
|
||||
package center.sciprog.maps.coordinates
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
public data class GmcBox(
|
||||
public val a: GeodeticMapCoordinates,
|
||||
public val b: GeodeticMapCoordinates,
|
||||
) {
|
||||
public companion object {
|
||||
public fun withCenter(
|
||||
center: GeodeticMapCoordinates,
|
||||
width: Distance,
|
||||
height: Distance,
|
||||
ellipsoid: Ellipsoid = Ellipsoid.WGS84,
|
||||
): GmcBox {
|
||||
val r = ellipsoid.equatorRadius * cos(center.latitude)
|
||||
val a = GeodeticMapCoordinates.ofRadians(
|
||||
center.latitude - height / ellipsoid.polarRadius / 2,
|
||||
center.longitude - width / r / 2
|
||||
)
|
||||
val b = GeodeticMapCoordinates.ofRadians(
|
||||
center.latitude + height / ellipsoid.polarRadius / 2,
|
||||
center.longitude + width / r / 2
|
||||
)
|
||||
return GmcBox(a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public val GmcBox.center: GeodeticMapCoordinates
|
||||
get() = GeodeticMapCoordinates.ofRadians(
|
||||
(a.latitude + b.latitude) / 2,
|
||||
(a.longitude + b.longitude) / 2
|
||||
)
|
||||
|
||||
/**
|
||||
* Minimum longitude
|
||||
*/
|
||||
public val GmcBox.left: Double get() = min(a.longitude, b.longitude)
|
||||
|
||||
/**
|
||||
* maximum longitude
|
||||
*/
|
||||
public val GmcBox.right: Double get() = max(a.longitude, b.longitude)
|
||||
|
||||
/**
|
||||
* Maximum latitude
|
||||
*/
|
||||
public val GmcBox.top: Double get() = max(a.latitude, b.latitude)
|
||||
|
||||
/**
|
||||
* Minimum latitude
|
||||
*/
|
||||
public val GmcBox.bottom: Double get() = min(a.latitude, b.latitude)
|
||||
|
||||
//TODO take curvature into account
|
||||
public val GmcBox.width: Double get() = abs(a.longitude - b.longitude)
|
||||
public val GmcBox.height: Double get() = abs(a.latitude - b.latitude)
|
||||
|
||||
public val GmcBox.topLeft: GeodeticMapCoordinates get() = GeodeticMapCoordinates.ofRadians(top, left)
|
||||
public val GmcBox.bottomRight: GeodeticMapCoordinates get() = GeodeticMapCoordinates.ofRadians(bottom, right)
|
||||
|
||||
/**
|
||||
* Compute a minimal bounding box including all given boxes. Return null if collection is empty
|
||||
*/
|
||||
public fun Collection<GmcBox>.wrapAll(): GmcBox? {
|
||||
if (isEmpty()) return null
|
||||
//TODO optimize computation
|
||||
val minLat = minOf { it.bottom }
|
||||
val maxLat = maxOf { it.top }
|
||||
val minLong = minOf { it.left }
|
||||
val maxLong = maxOf { it.right }
|
||||
return GmcBox(GeodeticMapCoordinates.ofRadians(minLat, minLong), GeodeticMapCoordinates.ofRadians(maxLat, maxLong))
|
||||
}
|
||||
@@ -1,33 +1,18 @@
|
||||
package centre.sciprog.maps
|
||||
package center.sciprog.maps.coordinates
|
||||
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Observable position on the map. Includes observation coordinate and [zoom] factor
|
||||
*/
|
||||
data class MapViewPoint(
|
||||
public data class MapViewPoint(
|
||||
val focus: GeodeticMapCoordinates,
|
||||
val zoom: Double,
|
||||
) {
|
||||
val scaleFactor by lazy { WebMercatorProjection.scaleFactor(zoom) }
|
||||
val scaleFactor: Double by lazy { WebMercatorProjection.scaleFactor(zoom) }
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
internal fun MapViewPoint.move(deltaX: Double, deltaY: Double): MapViewPoint {
|
||||
val newCoordinates = GeodeticMapCoordinates.ofRadians(
|
||||
(focus.latitude + deltaY / scaleFactor).coerceIn(
|
||||
-MercatorProjection.MAXIMUM_LATITUDE,
|
||||
MercatorProjection.MAXIMUM_LATITUDE
|
||||
),
|
||||
focus.longitude + deltaX / scaleFactor
|
||||
)
|
||||
return MapViewPoint(newCoordinates, zoom)
|
||||
}
|
||||
|
||||
fun MapViewPoint.move(delta: GeodeticMapCoordinates): MapViewPoint {
|
||||
public fun MapViewPoint.move(delta: GeodeticMapCoordinates): MapViewPoint {
|
||||
val newCoordinates = GeodeticMapCoordinates.ofRadians(
|
||||
(focus.latitude + delta.latitude).coerceIn(
|
||||
-MercatorProjection.MAXIMUM_LATITUDE,
|
||||
@@ -38,7 +23,7 @@ fun MapViewPoint.move(delta: GeodeticMapCoordinates): MapViewPoint {
|
||||
return MapViewPoint(newCoordinates, zoom)
|
||||
}
|
||||
|
||||
fun MapViewPoint.zoom(
|
||||
public fun MapViewPoint.zoom(
|
||||
zoomDelta: Double,
|
||||
invariant: GeodeticMapCoordinates = focus,
|
||||
): MapViewPoint = if (invariant == focus) {
|
||||
@@ -3,7 +3,7 @@
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package centre.sciprog.maps
|
||||
package center.sciprog.maps.coordinates
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package centre.sciprog.maps
|
||||
package center.sciprog.maps.coordinates
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
@@ -14,7 +14,7 @@ public object WebMercatorProjection {
|
||||
/**
|
||||
* Compute radians to projection coordinates ratio for given [zoom] factor
|
||||
*/
|
||||
public fun scaleFactor(zoom: Double) = 256.0 / 2 / PI * 2.0.pow(zoom)
|
||||
public fun scaleFactor(zoom: Double): Double = 256.0 / 2 / PI * 2.0.pow(zoom)
|
||||
|
||||
public fun toGeodetic(mercator: WebMercatorCoordinates): GeodeticMapCoordinates {
|
||||
val scaleFactor = scaleFactor(mercator.zoom.toDouble())
|
||||
30
maps-kt-scheme/build.gradle.kts
Normal file
30
maps-kt-scheme/build.gradle.kts
Normal file
@@ -0,0 +1,30 @@
|
||||
import org.jetbrains.compose.compose
|
||||
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
id("org.jetbrains.compose")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = "11"
|
||||
}
|
||||
withJava()
|
||||
}
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api("io.github.microutils:kotlin-logging:2.1.23")
|
||||
api(compose.foundation)
|
||||
}
|
||||
}
|
||||
val jvmMain by getting {
|
||||
dependencies {
|
||||
api(compose.desktop.currentOs)
|
||||
}
|
||||
}
|
||||
val jvmTest by getting
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package center.sciprog.maps.scheme
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
data class SchemeCoordinates(val x: Float, val y: Float)
|
||||
|
||||
data class SchemeCoordinateBox(
|
||||
val a: SchemeCoordinates,
|
||||
val b: SchemeCoordinates,
|
||||
)
|
||||
|
||||
val SchemeCoordinateBox.top get() = max(a.y, b.y)
|
||||
val SchemeCoordinateBox.bottom get() = min(a.y, b.y)
|
||||
|
||||
val SchemeCoordinateBox.right get() = max(a.x, b.x)
|
||||
val SchemeCoordinateBox.left get() = min(a.x, b.x)
|
||||
|
||||
val SchemeCoordinateBox.width get() = abs(a.x - b.x)
|
||||
val SchemeCoordinateBox.height get() = abs(a.y - b.y)
|
||||
|
||||
val SchemeCoordinateBox.center get() = SchemeCoordinates((a.x + b.x) / 2, (a.y + b.y) / 2)
|
||||
|
||||
|
||||
fun Collection<SchemeCoordinateBox>.wrapAll(): SchemeCoordinateBox? {
|
||||
if (isEmpty()) return null
|
||||
val minX = minOf { it.left }
|
||||
val maxX = maxOf { it.right }
|
||||
|
||||
val minY = minOf { it.bottom }
|
||||
val maxY = maxOf { it.top }
|
||||
return SchemeCoordinateBox(
|
||||
SchemeCoordinates(minX, minY),
|
||||
SchemeCoordinates(maxX, maxY)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package center.sciprog.maps.scheme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import center.sciprog.maps.scheme.SchemeFeature.Companion.defaultScaleRange
|
||||
|
||||
internal typealias FloatRange = ClosedFloatingPointRange<Float>
|
||||
|
||||
sealed class SchemeFeature(val scaleRange: FloatRange) {
|
||||
abstract fun getBoundingBox(scale: Float): SchemeCoordinateBox?
|
||||
|
||||
companion object {
|
||||
val defaultScaleRange = 0f..Float.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun Iterable<SchemeFeature>.computeBoundingBox(scale: Float): SchemeCoordinateBox? =
|
||||
mapNotNull { it.getBoundingBox(scale) }.wrapAll()
|
||||
|
||||
|
||||
internal fun Pair<Number, Number>.toCoordinates() = SchemeCoordinates(first.toFloat(), second.toFloat())
|
||||
|
||||
/**
|
||||
* A background image that is bound to scheme coordinates and is scaled together with them
|
||||
*
|
||||
* @param position the size of background in scheme size units. The screen units to scheme units ratio equals scale.
|
||||
*/
|
||||
class SchemeBackgroundFeature(
|
||||
val position: SchemeCoordinateBox,
|
||||
val painter: Painter,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = position
|
||||
}
|
||||
|
||||
class SchemeFeatureSelector(val selector: (scale: Float) -> SchemeFeature) : SchemeFeature(defaultScaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox? = selector(scale).getBoundingBox(scale)
|
||||
}
|
||||
|
||||
class SchemeDrawFeature(
|
||||
val position: SchemeCoordinates,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
val drawFeature: DrawScope.() -> Unit,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(position, position)
|
||||
}
|
||||
|
||||
class SchemeCircleFeature(
|
||||
val center: SchemeCoordinates,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
val size: Float = 5f,
|
||||
val color: Color = Color.Red,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(center, center)
|
||||
}
|
||||
|
||||
class SchemeLineFeature(
|
||||
val a: SchemeCoordinates,
|
||||
val b: SchemeCoordinates,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
val color: Color = Color.Red,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(a, b)
|
||||
}
|
||||
|
||||
class SchemeTextFeature(
|
||||
val position: SchemeCoordinates,
|
||||
val text: String,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
val color: Color = Color.Red,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(position, position)
|
||||
}
|
||||
|
||||
class SchemeBitmapFeature(
|
||||
val position: SchemeCoordinates,
|
||||
val image: ImageBitmap,
|
||||
val size: IntSize = IntSize(15, 15),
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(position, position)
|
||||
}
|
||||
|
||||
class SchemeImageFeature(
|
||||
val position: SchemeCoordinates,
|
||||
val painter: Painter,
|
||||
val size: DpSize,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(position, position)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SchemeVectorImageFeature(
|
||||
position: SchemeCoordinates,
|
||||
image: ImageVector,
|
||||
size: DpSize = DpSize(20.dp, 20.dp),
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
): SchemeImageFeature = SchemeImageFeature(position, rememberVectorPainter(image), size, scaleRange)
|
||||
|
||||
/**
|
||||
* A group of other features
|
||||
*/
|
||||
class SchemeFeatureGroup(
|
||||
val children: Map<FeatureId, SchemeFeature>,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox? =
|
||||
children.values.mapNotNull { it.getBoundingBox(scale) }.wrapAll()
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package center.sciprog.maps.scheme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import center.sciprog.maps.scheme.SchemeFeature.Companion.defaultScaleRange
|
||||
|
||||
typealias FeatureId = String
|
||||
|
||||
interface SchemeFeatureBuilder {
|
||||
fun addFeature(id: FeatureId?, feature: SchemeFeature): FeatureId
|
||||
|
||||
fun build(): SnapshotStateMap<FeatureId, SchemeFeature>
|
||||
}
|
||||
|
||||
internal class SchemeFeatureBuilderImpl(
|
||||
initialFeatures: Map<FeatureId, SchemeFeature>,
|
||||
) : SchemeFeatureBuilder {
|
||||
|
||||
private val content: SnapshotStateMap<FeatureId, SchemeFeature> =
|
||||
mutableStateMapOf<FeatureId, SchemeFeature>().apply {
|
||||
putAll(initialFeatures)
|
||||
}
|
||||
|
||||
private fun generateID(feature: SchemeFeature): FeatureId = "@feature[${feature.hashCode().toUInt()}]"
|
||||
|
||||
override fun addFeature(id: FeatureId?, feature: SchemeFeature): FeatureId {
|
||||
val safeId = id ?: generateID(feature)
|
||||
content[id ?: generateID(feature)] = feature
|
||||
return safeId
|
||||
}
|
||||
|
||||
override fun build(): SnapshotStateMap<FeatureId, SchemeFeature> = content
|
||||
}
|
||||
|
||||
fun SchemeFeatureBuilder.background(
|
||||
painter: Painter,
|
||||
box: SchemeCoordinateBox,
|
||||
id: FeatureId? = null,
|
||||
): FeatureId = addFeature(
|
||||
id,
|
||||
SchemeBackgroundFeature(box, painter)
|
||||
)
|
||||
|
||||
fun SchemeFeatureBuilder.background(
|
||||
painter: Painter,
|
||||
size: Size = painter.intrinsicSize,
|
||||
offset: SchemeCoordinates = SchemeCoordinates(0f, 0f),
|
||||
id: FeatureId? = null,
|
||||
): FeatureId {
|
||||
val box = SchemeCoordinateBox(
|
||||
offset,
|
||||
SchemeCoordinates(size.width + offset.x, size.height + offset.y)
|
||||
)
|
||||
return background(painter, box, id)
|
||||
}
|
||||
|
||||
fun SchemeFeatureBuilder.circle(
|
||||
center: SchemeCoordinates,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
size: Float = 5f,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(
|
||||
id, SchemeCircleFeature(center, scaleRange, size, color)
|
||||
)
|
||||
|
||||
fun SchemeFeatureBuilder.circle(
|
||||
centerCoordinates: Pair<Number, Number>,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
size: Float = 5f,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(
|
||||
id, SchemeCircleFeature(centerCoordinates.toCoordinates(), scaleRange, size, color)
|
||||
)
|
||||
|
||||
fun SchemeFeatureBuilder.draw(
|
||||
position: Pair<Number, Number>,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
id: FeatureId? = null,
|
||||
drawFeature: DrawScope.() -> Unit,
|
||||
) = addFeature(id, SchemeDrawFeature(position.toCoordinates(), scaleRange, drawFeature))
|
||||
|
||||
fun SchemeFeatureBuilder.line(
|
||||
aCoordinates: Pair<Number, Number>,
|
||||
bCoordinates: Pair<Number, Number>,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, SchemeLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), scaleRange, color))
|
||||
|
||||
fun SchemeFeatureBuilder.text(
|
||||
position: SchemeCoordinates,
|
||||
text: String,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, SchemeTextFeature(position, text, scaleRange, color))
|
||||
|
||||
fun SchemeFeatureBuilder.text(
|
||||
position: Pair<Number, Number>,
|
||||
text: String,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, SchemeTextFeature(position.toCoordinates(), text, scaleRange, color))
|
||||
|
||||
@Composable
|
||||
fun SchemeFeatureBuilder.image(
|
||||
position: Pair<Number, Number>,
|
||||
image: ImageVector,
|
||||
size: DpSize = DpSize(20.dp, 20.dp),
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, SchemeVectorImageFeature(position.toCoordinates(), image, size, scaleRange))
|
||||
|
||||
fun SchemeFeatureBuilder.group(
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
id: FeatureId? = null,
|
||||
builder: SchemeFeatureBuilder.() -> Unit,
|
||||
): FeatureId {
|
||||
val map = SchemeFeatureBuilderImpl(emptyMap()).apply(builder).build()
|
||||
val feature = SchemeFeatureGroup(map, scaleRange)
|
||||
return addFeature(id, feature)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package center.sciprog.maps.scheme
|
||||
|
||||
import kotlin.math.pow
|
||||
|
||||
data class SchemeViewPoint(val focus: SchemeCoordinates, val scale: Float = 1f)
|
||||
|
||||
fun SchemeViewPoint.move(deltaX: Float, deltaY: Float): SchemeViewPoint {
|
||||
return copy(focus = SchemeCoordinates(focus.x + deltaX, focus.y + deltaY))
|
||||
}
|
||||
|
||||
fun SchemeViewPoint.zoom(
|
||||
zoom: Float,
|
||||
invariant: SchemeCoordinates = focus,
|
||||
): SchemeViewPoint = if (invariant == focus) {
|
||||
copy(scale = scale * 2f.pow(zoom))
|
||||
} else {
|
||||
val difScale = (1 - 2f.pow(-zoom))
|
||||
val newCenter = SchemeCoordinates(
|
||||
focus.x + (invariant.x - focus.x) * difScale,
|
||||
focus.y + (invariant.y - focus.y) * difScale
|
||||
)
|
||||
SchemeViewPoint(newCenter, scale * 2f.pow(zoom))
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package center.sciprog.maps.scheme
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
import androidx.compose.foundation.gestures.forEachGesture
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.*
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import mu.KotlinLogging
|
||||
import org.jetbrains.skia.Font
|
||||
import org.jetbrains.skia.Paint
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
private fun Color.toPaint(): Paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = toArgb()
|
||||
}
|
||||
|
||||
private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last)
|
||||
|
||||
private val logger = KotlinLogging.logger("SchemeView")
|
||||
|
||||
data class SchemeViewConfig(
|
||||
val zoomSpeed: Float = 1f / 3f,
|
||||
val inferViewBoxFromFeatures: Boolean = false,
|
||||
val onClick: SchemeViewPoint.() -> Unit = {},
|
||||
val onViewChange: SchemeViewPoint.() -> Unit = {},
|
||||
val onSelect: (SchemeCoordinateBox) -> Unit = {},
|
||||
val zoomOnSelect: Boolean = true,
|
||||
)
|
||||
|
||||
@Composable
|
||||
public fun SchemeView(
|
||||
computeViewPoint: (canvasSize: DpSize) -> SchemeViewPoint,
|
||||
features: Map<FeatureId, SchemeFeature>,
|
||||
config: SchemeViewConfig = SchemeViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
|
||||
var canvasSize by remember { mutableStateOf(DpSize(512.dp, 512.dp)) }
|
||||
|
||||
var viewPointInternal: SchemeViewPoint? by remember {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
|
||||
val viewPoint: SchemeViewPoint by derivedStateOf {
|
||||
viewPointInternal ?: if (config.inferViewBoxFromFeatures) {
|
||||
features.values.computeBoundingBox(1f)?.let { box ->
|
||||
val scale = min(
|
||||
canvasSize.width.value / box.width,
|
||||
canvasSize.height.value / box.height
|
||||
)
|
||||
SchemeViewPoint(box.center, scale)
|
||||
} ?: computeViewPoint(canvasSize)
|
||||
} else {
|
||||
computeViewPoint(canvasSize)
|
||||
}
|
||||
}
|
||||
|
||||
fun DpOffset.toCoordinates(): SchemeCoordinates = SchemeCoordinates(
|
||||
(x - canvasSize.width / 2).value / viewPoint.scale + viewPoint.focus.x,
|
||||
(canvasSize.height / 2 - y).value / viewPoint.scale + viewPoint.focus.y
|
||||
)
|
||||
|
||||
// Selection rectangle. If null - no selection
|
||||
var selectRect by remember { mutableStateOf<Rect?>(null) }
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
val canvasModifier = modifier.pointerInput(Unit) {
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
|
||||
|
||||
val event: PointerEvent = awaitPointerEvent()
|
||||
event.changes.forEach { change ->
|
||||
if (event.buttons.isPrimaryPressed) {
|
||||
//Evaluating selection frame
|
||||
if (event.keyboardModifiers.isShiftPressed) {
|
||||
selectRect = Rect(change.position, change.position)
|
||||
drag(change.id) { dragChange ->
|
||||
selectRect?.let { rect ->
|
||||
val offset = dragChange.position
|
||||
selectRect = Rect(
|
||||
min(offset.x, rect.left),
|
||||
min(offset.y, rect.top),
|
||||
max(offset.x, rect.right),
|
||||
max(offset.y, rect.bottom)
|
||||
)
|
||||
}
|
||||
}
|
||||
selectRect?.let { rect ->
|
||||
//Use selection override if it is defined
|
||||
val box = SchemeCoordinateBox(
|
||||
rect.topLeft.toDpOffset().toCoordinates(),
|
||||
rect.bottomRight.toDpOffset().toCoordinates()
|
||||
)
|
||||
config.onSelect(box)
|
||||
if (config.zoomOnSelect) {
|
||||
val newScale = min(
|
||||
canvasSize.width.value / box.width,
|
||||
canvasSize.height.value / box.height
|
||||
)
|
||||
|
||||
val newViewPoint = SchemeViewPoint(box.center, newScale)
|
||||
|
||||
config.onViewChange(newViewPoint)
|
||||
viewPointInternal = newViewPoint
|
||||
}
|
||||
selectRect = null
|
||||
}
|
||||
} else {
|
||||
val dragStart = change.position
|
||||
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
|
||||
config.onClick(SchemeViewPoint(dpPos.toCoordinates(), viewPoint.scale))
|
||||
drag(change.id) { dragChange ->
|
||||
val dragAmount = dragChange.position - dragChange.previousPosition
|
||||
val newViewPoint = viewPoint.move(
|
||||
-dragAmount.x.toDp().value / viewPoint.scale,
|
||||
dragAmount.y.toDp().value / viewPoint.scale
|
||||
)
|
||||
config.onViewChange(newViewPoint)
|
||||
viewPointInternal = newViewPoint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onPointerEvent(PointerEventType.Scroll) {
|
||||
val change = it.changes.first()
|
||||
val (xPos, yPos) = change.position
|
||||
//compute invariant point of translation
|
||||
val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toCoordinates()
|
||||
val newViewPoint = viewPoint.zoom(-change.scrollDelta.y * config.zoomSpeed, invariant)
|
||||
config.onViewChange(newViewPoint)
|
||||
viewPointInternal = newViewPoint
|
||||
}.fillMaxSize()
|
||||
|
||||
Canvas(canvasModifier) {
|
||||
fun SchemeCoordinates.toOffset(): Offset = Offset(
|
||||
(canvasSize.width / 2 + (x.dp - viewPoint.focus.x.dp) * viewPoint.scale).toPx(),
|
||||
(canvasSize.height / 2 + (viewPoint.focus.y.dp - y.dp) * viewPoint.scale).toPx()
|
||||
)
|
||||
|
||||
|
||||
fun DrawScope.drawFeature(scale: Float, feature: SchemeFeature) {
|
||||
when (feature) {
|
||||
is SchemeBackgroundFeature -> {
|
||||
val offset = SchemeCoordinates(feature.position.left, feature.position.top).toOffset()
|
||||
|
||||
val backgroundSize = DpSize(
|
||||
(feature.position.width * scale).dp,
|
||||
(feature.position.height * scale).dp
|
||||
).toSize()
|
||||
|
||||
translate(offset.x, offset.y) {
|
||||
with(feature.painter) {
|
||||
draw(backgroundSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
is SchemeFeatureSelector -> drawFeature(scale, feature.selector(scale))
|
||||
is SchemeCircleFeature -> drawCircle(
|
||||
feature.color,
|
||||
feature.size,
|
||||
center = feature.center.toOffset()
|
||||
)
|
||||
is SchemeLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
|
||||
is SchemeBitmapFeature -> drawImage(feature.image, feature.position.toOffset())
|
||||
is SchemeImageFeature -> {
|
||||
val offset = feature.position.toOffset()
|
||||
val imageSize = feature.size.toSize()
|
||||
translate(offset.x - imageSize.width / 2, offset.y - imageSize.height / 2) {
|
||||
with(feature.painter) {
|
||||
draw(imageSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
is SchemeTextFeature -> drawIntoCanvas { canvas ->
|
||||
val offset = feature.position.toOffset()
|
||||
canvas.nativeCanvas.drawString(
|
||||
feature.text,
|
||||
offset.x + 5,
|
||||
offset.y - 5,
|
||||
Font().apply { size = 16f },
|
||||
feature.color.toPaint()
|
||||
)
|
||||
}
|
||||
is SchemeDrawFeature -> {
|
||||
val offset = feature.position.toOffset()
|
||||
translate(offset.x, offset.y) {
|
||||
feature.drawFeature(this)
|
||||
}
|
||||
}
|
||||
is SchemeFeatureGroup -> {
|
||||
feature.children.values.forEach {
|
||||
drawFeature(scale, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (canvasSize != size.toDpSize()) {
|
||||
canvasSize = size.toDpSize()
|
||||
logger.debug { "Recalculate canvas. Size: $size" }
|
||||
}
|
||||
clipRect {
|
||||
features.values.filterIsInstance<SchemeBackgroundFeature>().forEach { background ->
|
||||
drawFeature(viewPoint.scale, background)
|
||||
}
|
||||
features.values.filter {
|
||||
it !is SchemeBackgroundFeature && viewPoint.scale in it.scaleRange
|
||||
}.forEach { feature ->
|
||||
drawFeature(viewPoint.scale, feature)
|
||||
}
|
||||
}
|
||||
selectRect?.let { rect ->
|
||||
drawRect(
|
||||
color = Color.Blue,
|
||||
topLeft = rect.topLeft,
|
||||
size = rect.size,
|
||||
alpha = 0.5f,
|
||||
style = Stroke(
|
||||
width = 2f,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SchemeView(
|
||||
initialViewPoint: SchemeViewPoint,
|
||||
features: Map<FeatureId, SchemeFeature> = emptyMap(),
|
||||
config: SchemeViewConfig = SchemeViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
buildFeatures: @Composable (SchemeFeatureBuilder.() -> Unit) = {},
|
||||
) {
|
||||
val featuresBuilder = SchemeFeatureBuilderImpl(features)
|
||||
featuresBuilder.buildFeatures()
|
||||
SchemeView(
|
||||
{ initialViewPoint },
|
||||
featuresBuilder.build(),
|
||||
config,
|
||||
modifier
|
||||
)
|
||||
}
|
||||
BIN
maps-kt-scheme/src/jvmMain/resources/middle-earth.jpg
Normal file
BIN
maps-kt-scheme/src/jvmMain/resources/middle-earth.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 469 KiB |
@@ -1,4 +1,7 @@
|
||||
// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
rootProject.name = "maps-kt"
|
||||
|
||||
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
@@ -9,9 +12,19 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform").version(extra["kotlin.version"] as String)
|
||||
kotlin("android").version(extra["kotlin.version"] as String)
|
||||
id("com.android.application").version(extra["agp.version"] as String)
|
||||
id("com.android.library").version(extra["agp.version"] as String)
|
||||
id("org.jetbrains.compose").version(extra["compose.version"] as String)
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "maps-kt-compose"
|
||||
|
||||
include(
|
||||
":maps-kt-core",
|
||||
":maps-kt-compose",
|
||||
":demo:maps",
|
||||
":maps-kt-scheme",
|
||||
":demo:scheme"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package centre.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
typealias FeatureId = String
|
||||
|
||||
interface FeatureBuilder {
|
||||
fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId
|
||||
|
||||
fun build(): SnapshotStateMap<FeatureId, MapFeature>
|
||||
}
|
||||
|
||||
internal class MapFeatureBuilder(private val content: SnapshotStateMap<FeatureId, MapFeature> = mutableStateMapOf()) : FeatureBuilder {
|
||||
private fun generateID(feature: MapFeature): FeatureId = "@feature[${feature.hashCode().toUInt()}]"
|
||||
|
||||
override fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId {
|
||||
val safeId = id ?: generateID(feature)
|
||||
content[id ?: generateID(feature)] = feature
|
||||
return safeId
|
||||
}
|
||||
|
||||
override fun build(): SnapshotStateMap<FeatureId, MapFeature> = content
|
||||
}
|
||||
|
||||
fun FeatureBuilder.circle(
|
||||
centerCoordinates: Pair<Double, Double>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
size: Float = 5f,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(
|
||||
id, MapCircleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color)
|
||||
)
|
||||
|
||||
fun FeatureBuilder.line(
|
||||
aCoordinates: Pair<Double, Double>,
|
||||
bCoordinates: Pair<Double, Double>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, MapLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color))
|
||||
|
||||
fun FeatureBuilder.text(
|
||||
position: Pair<Double, Double>,
|
||||
text: String,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, MapTextFeature(position.toCoordinates(), text, zoomRange, color))
|
||||
|
||||
@Composable
|
||||
fun FeatureBuilder.image(
|
||||
position: Pair<Double, Double>,
|
||||
image: ImageVector,
|
||||
size: Size = Size(20f, 20f),
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, MapVectorImageFeature(position.toCoordinates(), image, size, zoomRange))
|
||||
@@ -1,67 +0,0 @@
|
||||
package centre.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import centre.sciprog.maps.GeodeticMapCoordinates
|
||||
|
||||
//TODO replace zoom range with zoom-based representation change
|
||||
sealed class MapFeature(val zoomRange: IntRange)
|
||||
|
||||
internal fun Pair<Double, Double>.toCoordinates() = GeodeticMapCoordinates.ofDegrees(first, second)
|
||||
|
||||
internal val defaultZoomRange = 1..18
|
||||
|
||||
/**
|
||||
* A feature that decides what to show depending on the zoom value (it could change size of shape)
|
||||
*/
|
||||
class MapFeatureSelector(val selector: (zoom: Int) -> MapFeature) : MapFeature(defaultZoomRange)
|
||||
|
||||
class MapCircleFeature(
|
||||
val center: GeodeticMapCoordinates,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
val size: Float = 5f,
|
||||
val color: Color = Color.Red,
|
||||
) : MapFeature(zoomRange)
|
||||
|
||||
class MapLineFeature(
|
||||
val a: GeodeticMapCoordinates,
|
||||
val b: GeodeticMapCoordinates,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
val color: Color = Color.Red,
|
||||
) : MapFeature(zoomRange)
|
||||
|
||||
class MapTextFeature(
|
||||
val position: GeodeticMapCoordinates,
|
||||
val text: String,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
val color: Color = Color.Red,
|
||||
) : MapFeature(zoomRange)
|
||||
|
||||
class MapBitmapImageFeature(
|
||||
val position: GeodeticMapCoordinates,
|
||||
val image: ImageBitmap,
|
||||
val size: IntSize = IntSize(15, 15),
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
) : MapFeature(zoomRange)
|
||||
|
||||
|
||||
class MapVectorImageFeature (
|
||||
val position: GeodeticMapCoordinates,
|
||||
val painter: Painter,
|
||||
val size: Size,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
) : MapFeature(zoomRange)
|
||||
|
||||
@Composable
|
||||
fun MapVectorImageFeature(
|
||||
position: GeodeticMapCoordinates,
|
||||
image: ImageVector,
|
||||
size: Size = Size(20f, 20f),
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
): MapVectorImageFeature = MapVectorImageFeature(position, rememberVectorPainter(image), size, zoomRange)
|
||||
@@ -1,29 +0,0 @@
|
||||
package centre.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import kotlin.math.floor
|
||||
|
||||
data class TileId(
|
||||
val zoom: Int,
|
||||
val i: Int,
|
||||
val j: Int,
|
||||
)
|
||||
|
||||
data class MapTile(
|
||||
val id: TileId,
|
||||
val image: ImageBitmap,
|
||||
)
|
||||
|
||||
interface MapTileProvider {
|
||||
suspend fun loadTile(id: TileId): MapTile
|
||||
|
||||
val tileSize: Int get() = DEFAULT_TILE_SIZE
|
||||
|
||||
fun toIndex(d: Double): Int = floor(d / tileSize).toInt()
|
||||
|
||||
fun toCoordinate(i: Int): Double = (i * tileSize).toDouble()
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_TILE_SIZE = 256
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package centre.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import centre.sciprog.maps.GeodeticMapCoordinates
|
||||
import centre.sciprog.maps.MapViewPoint
|
||||
|
||||
|
||||
data class MapViewConfig(
|
||||
val zoomSpeed: Double = 1.0 / 3.0,
|
||||
)
|
||||
|
||||
@Composable
|
||||
expect fun MapView(
|
||||
initialViewPoint: MapViewPoint,
|
||||
mapTileProvider: MapTileProvider,
|
||||
features: Map<FeatureId, MapFeature>,
|
||||
onClick: (GeodeticMapCoordinates) -> Unit = {},
|
||||
//TODO consider replacing by modifier
|
||||
config: MapViewConfig = MapViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MapView(
|
||||
initialViewPoint: MapViewPoint,
|
||||
mapTileProvider: MapTileProvider,
|
||||
onClick: (GeodeticMapCoordinates) -> Unit = {},
|
||||
config: MapViewConfig = MapViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
addFeatures: @Composable() (FeatureBuilder.() -> Unit) = {},
|
||||
) {
|
||||
val featuresBuilder = MapFeatureBuilder()
|
||||
featuresBuilder.addFeatures()
|
||||
MapView(initialViewPoint, mapTileProvider, featuresBuilder.build(), onClick, config, modifier)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import centre.sciprog.maps.GeodeticMapCoordinates
|
||||
import centre.sciprog.maps.MapViewPoint
|
||||
import centre.sciprog.maps.compose.*
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.nio.file.Path
|
||||
import kotlin.random.Random
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
MaterialTheme {
|
||||
//create a view point
|
||||
val viewPoint = remember {
|
||||
MapViewPoint(
|
||||
GeodeticMapCoordinates.ofDegrees(55.7558, 37.6173),
|
||||
8.0
|
||||
)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val mapTileProvider = remember { OpenStreetMapTileProvider(scope, HttpClient(CIO), Path.of("mapCache")) }
|
||||
|
||||
var coordinates by remember { mutableStateOf<GeodeticMapCoordinates?>(null) }
|
||||
|
||||
Column {
|
||||
//display click coordinates
|
||||
Text(coordinates?.toString() ?: "")
|
||||
MapView(viewPoint, mapTileProvider, onClick = { gmc: GeodeticMapCoordinates -> coordinates = gmc }) {
|
||||
val pointOne = 55.568548 to 37.568604
|
||||
val pointTwo = 55.929444 to 37.518434
|
||||
|
||||
image(pointOne, Icons.Filled.Home)
|
||||
|
||||
//remember feature Id
|
||||
val circleId: FeatureId = circle(pointTwo)
|
||||
|
||||
line(pointOne, pointTwo)
|
||||
text(pointOne, "Home")
|
||||
|
||||
scope.launch {
|
||||
while (isActive){
|
||||
delay(200)
|
||||
//Overwrite a feature with new color
|
||||
circle(pointTwo, id = circleId, color = Color(Random.nextFloat(), Random.nextFloat(), Random.nextFloat()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun main() = application {
|
||||
Window(onCloseRequest = ::exitApplication) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
package centre.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
import androidx.compose.foundation.gestures.forEachGesture
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.*
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.unit.*
|
||||
import centre.sciprog.maps.*
|
||||
import mu.KotlinLogging
|
||||
import org.jetbrains.skia.Font
|
||||
import org.jetbrains.skia.Paint
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.pow
|
||||
|
||||
|
||||
private fun Color.toPaint(): Paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = toArgb()
|
||||
}
|
||||
|
||||
private val logger = KotlinLogging.logger("MapView")
|
||||
|
||||
/**
|
||||
* A component that renders map and provides basic map manipulation capabilities
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
actual fun MapView(
|
||||
initialViewPoint: MapViewPoint,
|
||||
mapTileProvider: MapTileProvider,
|
||||
features: Map<FeatureId, MapFeature>,
|
||||
onClick: (GeodeticMapCoordinates) -> Unit,
|
||||
config: MapViewConfig,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
|
||||
var viewPoint by remember { mutableStateOf(initialViewPoint) }
|
||||
|
||||
val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() }
|
||||
|
||||
val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) }
|
||||
|
||||
val mapTiles = remember { mutableStateListOf<MapTile>() }
|
||||
|
||||
//var mapRectangle by remember { mutableStateOf(initialRectangle) }
|
||||
var canvasSize by remember { mutableStateOf(DpSize(512.dp, 512.dp)) }
|
||||
|
||||
val centerCoordinates by derivedStateOf { WebMercatorProjection.toMercator(viewPoint.focus, zoom) }
|
||||
|
||||
fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates(
|
||||
zoom,
|
||||
(x - canvasSize.width / 2).value / tileScale + centerCoordinates.x,
|
||||
(y - canvasSize.height / 2).value / tileScale + centerCoordinates.y,
|
||||
)
|
||||
|
||||
/*
|
||||
* Convert screen independent offset to GMC, adjusting for fractional zoom
|
||||
*/
|
||||
fun DpOffset.toGeodetic() = WebMercatorProjection.toGeodetic(toMercator())
|
||||
|
||||
// Selection rectangle. If null - no selection
|
||||
var selectRect by remember { mutableStateOf<Rect?>(null) }
|
||||
|
||||
val canvasModifier = modifier.pointerInput(Unit) {
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
val event: PointerEvent = awaitPointerEvent()
|
||||
event.changes.forEach { change ->
|
||||
if (event.buttons.isPrimaryPressed) {
|
||||
//Evaluating selection frame
|
||||
if (event.keyboardModifiers.isShiftPressed) {
|
||||
selectRect = Rect(change.position, change.position)
|
||||
drag(change.id) { dragChange ->
|
||||
selectRect?.let { rect ->
|
||||
val offset = dragChange.position
|
||||
selectRect = Rect(
|
||||
kotlin.math.min(offset.x, rect.left),
|
||||
kotlin.math.min(offset.y, rect.top),
|
||||
kotlin.math.max(offset.x, rect.right),
|
||||
kotlin.math.max(offset.y, rect.bottom)
|
||||
)
|
||||
}
|
||||
}
|
||||
selectRect?.let { rect ->
|
||||
val (centerX, centerY) = rect.center
|
||||
val centerGmc = DpOffset(centerX.toDp(), centerY.toDp()).toGeodetic()
|
||||
|
||||
val horizontalZoom: Float = log2(canvasSize.width.toPx() / rect.width)
|
||||
val verticalZoom: Float = log2(canvasSize.height.toPx() / rect.height)
|
||||
|
||||
|
||||
viewPoint = MapViewPoint(centerGmc, viewPoint.zoom + kotlin.math.min(verticalZoom, horizontalZoom))
|
||||
selectRect = null
|
||||
}
|
||||
} else {
|
||||
val dragStart = change.position
|
||||
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
|
||||
onClick(dpPos.toGeodetic())
|
||||
drag(change.id) { dragChange ->
|
||||
val dragAmount = dragChange.position - dragChange.previousPosition
|
||||
viewPoint = viewPoint.move(
|
||||
-dragAmount.x.toDp().value / tileScale,
|
||||
+dragAmount.y.toDp().value / tileScale
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onPointerEvent(PointerEventType.Scroll) {
|
||||
val change = it.changes.first()
|
||||
val (xPos, yPos) = change.position
|
||||
//compute invariant point of translation
|
||||
val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toGeodetic()
|
||||
viewPoint = viewPoint.zoom(-change.scrollDelta.y.toDouble() * config.zoomSpeed, invariant)
|
||||
}.fillMaxSize()
|
||||
|
||||
|
||||
// Load tiles asynchronously
|
||||
LaunchedEffect(viewPoint, canvasSize) {
|
||||
val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale
|
||||
val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale
|
||||
val horizontalIndices = mapTileProvider.toIndex(left)..mapTileProvider.toIndex(right)
|
||||
|
||||
val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale)
|
||||
val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale)
|
||||
val verticalIndices = mapTileProvider.toIndex(bottom)..mapTileProvider.toIndex(top)
|
||||
|
||||
mapTiles.clear()
|
||||
|
||||
val indexRange = 0 until 2.0.pow(zoom).toInt()
|
||||
|
||||
for (j in verticalIndices) {
|
||||
for (i in horizontalIndices) {
|
||||
if (i in indexRange && j in indexRange) {
|
||||
val tileId = TileId(zoom, i, j)
|
||||
try {
|
||||
val tile = mapTileProvider.loadTile(tileId)
|
||||
mapTiles.add(tile)
|
||||
} catch (ex: Exception) {
|
||||
logger.error(ex) { "Failed to load tile $tileId" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// d
|
||||
Canvas(canvasModifier) {
|
||||
|
||||
fun WebMercatorCoordinates.toOffset(): Offset = Offset(
|
||||
(canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()).toPx(),
|
||||
(canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()).toPx()
|
||||
)
|
||||
|
||||
//Convert GMC to offset in pixels (not DP), adjusting for zoom
|
||||
fun GeodeticMapCoordinates.toOffset(): Offset = WebMercatorProjection.toMercator(this, zoom).toOffset()
|
||||
|
||||
|
||||
fun DrawScope.drawFeature(zoom: Int, feature: MapFeature) {
|
||||
when (feature) {
|
||||
is MapFeatureSelector -> drawFeature(zoom, feature.selector(zoom))
|
||||
is MapCircleFeature -> drawCircle(
|
||||
feature.color,
|
||||
feature.size,
|
||||
center = feature.center.toOffset()
|
||||
)
|
||||
is MapLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
|
||||
is MapBitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
|
||||
is MapVectorImageFeature -> {
|
||||
val offset = feature.position.toOffset()
|
||||
translate(offset.x - feature.size.width / 2, offset.y - feature.size.height / 2) {
|
||||
with(feature.painter) {
|
||||
draw(feature.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
is MapTextFeature -> drawIntoCanvas { canvas ->
|
||||
val offset = feature.position.toOffset()
|
||||
canvas.nativeCanvas.drawString(
|
||||
feature.text,
|
||||
offset.x + 5,
|
||||
offset.y - 5,
|
||||
Font().apply { size = 16f },
|
||||
feature.color.toPaint()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (canvasSize != size.toDpSize()) {
|
||||
canvasSize = size.toDpSize()
|
||||
logger.debug { "Recalculate canvas. Size: $size" }
|
||||
}
|
||||
clipRect {
|
||||
val tileSize = IntSize(
|
||||
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(),
|
||||
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt()
|
||||
)
|
||||
mapTiles.forEach { (id, image) ->
|
||||
//converting back from tile index to screen offset
|
||||
val offset = IntOffset(
|
||||
(canvasSize.width / 2 + (mapTileProvider.toCoordinate(id.i).dp - centerCoordinates.x.dp) * tileScale.toFloat()).roundToPx(),
|
||||
(canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale.toFloat()).roundToPx()
|
||||
)
|
||||
drawImage(
|
||||
image = image,
|
||||
dstOffset = offset,
|
||||
dstSize = tileSize
|
||||
)
|
||||
}
|
||||
features.values.filter { zoom in it.zoomRange }.forEach { feature ->
|
||||
drawFeature(zoom, feature)
|
||||
}
|
||||
}
|
||||
selectRect?.let { rect ->
|
||||
drawRect(
|
||||
color = Color.Blue,
|
||||
topLeft = rect.topLeft,
|
||||
size = rect.size,
|
||||
alpha = 0.5f,
|
||||
style = Stroke(
|
||||
width = 2f,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package centre.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.readBytes
|
||||
import kotlinx.coroutines.*
|
||||
import mu.KotlinLogging
|
||||
import org.jetbrains.skia.Image
|
||||
import java.net.URL
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.*
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* A [MapTileProvider] based on Open Street Map API. With in-memory and file cache
|
||||
*/
|
||||
public class OpenStreetMapTileProvider(private val scope: CoroutineScope, private val client: HttpClient, private val cacheDirectory: Path): MapTileProvider {
|
||||
private val cache = HashMap<TileId, Deferred<ImageBitmap>>()
|
||||
|
||||
private fun TileId.osmUrl() = URL("https://tile.openstreetmap.org/${zoom}/${i}/${j}.png")
|
||||
|
||||
private fun TileId.cacheFilePath() = cacheDirectory.resolve("${zoom}/${i}/${j}.png")
|
||||
|
||||
/**
|
||||
* Download and cache the tile image
|
||||
*/
|
||||
private fun downloadImageAsync(id: TileId) = scope.async(Dispatchers.IO) {
|
||||
id.cacheFilePath()?.let { path ->
|
||||
if (path.exists()) {
|
||||
try {
|
||||
return@async Image.makeFromEncoded(path.readBytes()).toComposeImageBitmap()
|
||||
} catch (ex: Exception) {
|
||||
logger.debug { "Failed to load image from $path" }
|
||||
path.deleteIfExists()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val url = id.osmUrl()
|
||||
val byteArray = client.get(url).readBytes()
|
||||
|
||||
logger.debug { "Finished downloading map tile with id $id from $url" }
|
||||
|
||||
id.cacheFilePath()?.let { path ->
|
||||
logger.debug { "Caching map tile $id to $path" }
|
||||
|
||||
path.parent.createDirectories()
|
||||
path.writeBytes(byteArray)
|
||||
}
|
||||
|
||||
Image.makeFromEncoded(byteArray).toComposeImageBitmap()
|
||||
}
|
||||
|
||||
override suspend fun loadTile(id: TileId): MapTile {
|
||||
val indexRange = indexRange(id.zoom)
|
||||
if(id.i !in indexRange || id.j !in indexRange){
|
||||
error("Indices (${id.i}, ${id.j}) are not in index range $indexRange for zoom ${id.zoom}")
|
||||
}
|
||||
|
||||
val image = cache.getOrPut(id) {
|
||||
downloadImageAsync(id)
|
||||
}.await()
|
||||
|
||||
return MapTile(id, image)
|
||||
}
|
||||
|
||||
companion object{
|
||||
private val logger = KotlinLogging.logger("OpenStreetMapCache")
|
||||
private fun indexRange(zoom: Int): IntRange = 0 until 2.0.pow(zoom).toInt()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user