52 Commits

Author SHA1 Message Date
237b9ec1c5 add resource for geotiff demo 2025-06-14 14:55:55 +03:00
3ae45dbc93 add resource for geotiff demo 2025-06-14 14:55:49 +03:00
e6dab6071e Update readme template 2025-06-14 14:53:01 +03:00
585d174fa4 Update readme template 2025-05-28 09:33:36 +03:00
67469b3d62 [WIP] geotools integration 2025-05-02 20:30:06 +03:00
558c217987 Add tiles feature builder 2025-05-01 11:15:02 +03:00
35c459d658 add docs to maps 2025-05-01 09:54:53 +03:00
e1a2ba06e1 add docs to scheme 2025-05-01 09:40:25 +03:00
a63490a3d5 Fix text measurement outside of screen bug 2025-05-01 09:28:41 +03:00
8b50045c6e Add ability to use multiple tiled layers 2025-04-30 09:43:17 +03:00
621ab06361 Update plugin and kotlin version 2024-12-29 14:49:55 +03:00
c1bb150dd8 Update plugin and kotlin version 2024-12-29 14:47:32 +03:00
4e08680b22 PNG export 2024-10-03 09:22:42 +03:00
bd2804d772 Move svg to features 2024-10-01 20:26:00 +03:00
e3b5ad0df4 Add proper serialization for trajectory 2024-08-13 20:17:01 +03:00
3ddced5c4a fix missing default modifier for MapView 2024-08-13 16:41:43 +03:00
2d46a0ad98 Fix package names in Trajectory 2024-08-13 16:26:31 +03:00
29a0fb743c Use data objects for trajectory directions 2024-07-21 18:40:17 +03:00
df800f05f0 Merge pull request 'Add requirement on non-emptiness of composite trajectory parts list' (!25) from lounres/maps-kt:fix/nonemptiness-check-for-composite-trajectories into dev
Reviewed-on: #25
Reviewed-by: Alexander Nozik <altavir@gmail.com>
2024-07-08 18:12:56 +03:00
Gleb Minaev
498db37a7c Add requirement on non-emptiness of composite trajectory parts list. 2024-07-08 18:08:54 +03:00
e913874ace Merge pull request 'immutable_features' (!24) from immutable_features into dev 2024-07-08 11:56:03 +03:00
4e76a25a15 Merge remote-tracking branch 'spc/immutable_features' into immutable_features 2024-07-08 11:55:20 +03:00
5da7ee7944 Remove unnecessary argument 2024-07-08 11:55:11 +03:00
1119d593a2 Revert "extract GroupAttributesCalculator"
This reverts commit bf128a3eb9.
2024-07-08 14:29:43 +06:00
bf128a3eb9 extract GroupAttributesCalculator 2024-07-08 14:03:59 +06:00
3a4c9133c6 Add bulk set for features 2024-07-08 09:13:42 +03:00
29074a9624 Fix feature update 2024-07-07 13:20:57 +03:00
62196fc6f5 Refactored to use flow instead of snapshot maps 2024-07-06 09:54:06 +03:00
0f5dcf9979 add github CI 2024-06-19 18:58:58 +03:00
601a16e420 Merge remote-tracking branch 'refs/remotes/github/main' into dev 2024-06-19 18:38:30 +03:00
SPC-code
7d0dcd1b91 Update pages.yml 2024-06-19 18:35:26 +03:00
SPC-code
b4b3ecc8d7 Update publish.yml 2024-06-19 18:23:36 +03:00
SPC-code
79f4d0eba5 Update pages.yml 2024-06-19 18:22:51 +03:00
SPC-code
390a896e0a Update build.yml 2024-06-19 18:19:10 +03:00
234d4715b6 add github CI 2024-06-19 18:14:59 +03:00
6eafb5ec26 make ./gradlew executable 2024-06-19 17:34:44 +06:00
SPC-code
bd6d8e2f8e Merge pull request #24 from SciProgCentre/dev
0.3.0
2024-06-12 14:26:56 +03:00
f62f8181ce avoid drawing invisible features 2024-06-07 19:28:38 +03:00
c30f586120 add alpha for remaining features 2024-06-07 12:07:49 +03:00
07ea73a87a add .kotlin to ignore 2024-06-04 15:16:25 +03:00
7ca4bba1b7 Version 0.3.0 2024-06-04 14:38:21 +03:00
7c7a788d2e change package to space.kscience 2024-06-04 11:06:50 +03:00
327cef9ea9 Update dependencies. Add wasm demo 2024-02-23 12:11:43 +03:00
ea7869e39d First preview of 0.3.0 2023-11-15 16:43:13 +03:00
d21d6ebb2a Add (not working yet) JS implementation 2023-10-01 14:05:03 +03:00
f05f6e137c Add Js targets 2023-10-01 11:22:02 +03:00
7d3b219d70 Move files around 2023-10-01 11:12:15 +03:00
aebf4af24f Fix svg export 2023-09-10 21:13:54 +03:00
75b5a69a27 Full refactor of map state 2023-09-10 13:12:45 +03:00
921aff4685 Merge remote-tracking branch 'space/dev' into dev 2023-05-26 15:49:31 +03:00
1caf141d27 Fix for #22 (default hairline size for points) 2023-05-26 15:49:14 +03:00
2bc595d97d Fix for #20 - auto-scale for single point 2023-05-11 09:39:08 +03:00
139 changed files with 3506 additions and 2292 deletions

22
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Gradle build
on:
push:
branches: [ dev ]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4.1.7
- name: Set up JDK 17
uses: actions/setup-java@v4.2.1
with:
java-version: 17
distribution: liberica
- name: execute build
uses: gradle/gradle-build-action@v3.4.2
with:
arguments: build

39
.github/workflows/pages.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Dokka publication
on:
push:
branches: [ main ]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: 17
distribution: liberica
- name: execute build
uses: gradle/gradle-build-action@v3
with:
arguments: dokkaHtmlMultiModule
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: 'build/dokka/htmlMultiModule'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

27
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Gradle publish
on:
workflow_dispatch:
release:
types: [ created ]
jobs:
publish:
environment:
name: publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.1.7
- name: Set up JDK 17
uses: actions/setup-java@v4.2.1
with:
java-version: 17
distribution: liberica
- name: execute build
uses: gradle/gradle-build-action@v3.4.2
- name: Publish
shell: bash
run: >
./gradlew release --no-daemon --build-cache -Ppublishing.enabled=true
-Ppublishing.space.user=${{ secrets.SPACE_APP_ID }}
-Ppublishing.space.token=${{ secrets.SPACE_APP_SECRET }}

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
build/
.gradle/
.idea/
.kotlin
/*.iml
mapCache/

View File

@@ -9,17 +9,17 @@ job("Publish") {
gitPush { enabled = false }
}
container("spc.registry.jetbrains.space/p/sci/containers/kotlin-ci:1.0.3") {
env["SPACE_USER"] = Secrets("space_user")
env["SPACE_TOKEN"] = Secrets("space_token")
env["SPACE_USER"] = "{{ project:space_user }}"
env["SPACE_TOKEN"] = "{{ project:space_token }}"
kotlinScript { api ->
val spaceUser = System.getenv("SPACE_USER")
val spaceToken = System.getenv("SPACE_TOKEN")
// write version to the build directory
// write the version to the build directory
api.gradlew("version")
//read version from build file
//read the version from build file
val version = java.nio.file.Path.of("build/project-version.txt").readText()
val revisionSuffix = if (version.endsWith("SNAPSHOT")) {
@@ -32,7 +32,7 @@ job("Publish") {
project = api.projectIdentifier(),
targetIdentifier = TargetIdentifier.Key("maps-kt"),
version = version+revisionSuffix,
// automatically update deployment status based on a status of a job
// automatically update deployment status based on the status of a job
syncWithAutomationJob = true
)
api.gradlew(

View File

@@ -3,13 +3,34 @@
## Unreleased
### Added
- `alpha` extension for feature attribute builder
- PNG export
### Changed
- Features are now sealed. New `CustomFeature` is not drawn by default draw.
- avoid drawing features with VisibleAttribute false
- Move SVG export to `features` and make it usable for maps as well
- Kotlin 2.1
### Deprecated
### Removed
### Fixed
- Text measurement outside of screen errors
- Add alpha attribute comprehension for all standard features.
- Package name for SerializeableAttribute
### Security
## 0.3.0 - 2024-06-04
### Changed
- Package changed to `space.kscience`
- Kotlin 2.0
### Fixed
- Use of generated resources for Wasm

View File

@@ -1,21 +1,38 @@
# Maps-kt
This repository is a work-in-progress implementation of Map-with-markers component for Compose-Multiplatform
A Kotlin Multiplatform library for interactive maps and geospatial data visualization using Compose Multiplatform.
![](docs/images/Screenshot%202023-01-12%20110429.png)
## Overview
Maps-kt provides a comprehensive set of tools for working with maps, geospatial data, and cartographic projections in Kotlin. It offers a UI-agnostic core with Compose Multiplatform implementations, allowing you to create interactive maps with markers, layers, and custom visualizations across multiple platforms.
## Features
- **Multiplatform Support**: Works on JVM, JavaScript, Native, and WebAssembly platforms
- **Compose Integration**: Seamless integration with Compose Multiplatform for modern UI development
- **Map Projections**: Support for Mercator, Web Mercator, and other cartographic projections
- **Geospatial Data**: Tools for working with coordinates, distances, angles, and ellipsoid geometry
- **Tile Providers**: Integration with OpenStreetMap and other tile providers
- **GeoJSON Support**: Parse and visualize GeoJSON data
- **Path Optimization**: Trajectory and path optimization capabilities
## License
This project is licensed under the Apache 2.0 License.
## Modules
### [demo](demo)
>
>
> **Maturity**: EXPERIMENTAL
### [maps-kt-compose](maps-kt-compose)
> Compose-multiplaform implementation for web-mercator tiled maps
>
> **Maturity**: EXPERIMENTAL
> **Maturity**: DEVELOPMENT
>
> **Features:**
> - [osm](maps-kt-compose/#) : OpenStreetMap tile provider.
@@ -33,19 +50,21 @@ This repository is a work-in-progress implementation of Map-with-markers compone
### [maps-kt-features](maps-kt-features)
>
>
> **Maturity**: EXPERIMENTAL
### [maps-kt-geojson](maps-kt-geojson)
>
> GeoJson format support
>
> **Maturity**: DEVELOPMENT
### [maps-kt-geotools](maps-kt-geotools)
>
> **Maturity**: EXPERIMENTAL
### [maps-kt-scheme](maps-kt-scheme)
>
>
> **Maturity**: EXPERIMENTAL
> **Maturity**: DEVELOPMENT
### [trajectory-kt](trajectory-kt)
> Path and trajectory optimization
@@ -53,21 +72,22 @@ This repository is a work-in-progress implementation of Map-with-markers compone
> **Maturity**: EXPERIMENTAL
### [demo/maps](demo/maps)
>
>
> **Maturity**: EXPERIMENTAL
### [demo/maps-wasm](demo/maps-wasm)
>
> **Maturity**: EXPERIMENTAL
### [demo/polygon-editor](demo/polygon-editor)
>
>
> **Maturity**: EXPERIMENTAL
### [demo/scheme](demo/scheme)
>
>
> **Maturity**: EXPERIMENTAL
### [demo/trajectory-playground](demo/trajectory-playground)
>
>
> **Maturity**: EXPERIMENTAL

View File

@@ -1,4 +1,3 @@
import space.kscience.gradle.isInDevelopment
import space.kscience.gradle.useApache2Licence
import space.kscience.gradle.useSPCTeam
@@ -6,16 +5,13 @@ plugins {
id("space.kscience.gradle.project")
}
val kmathVersion: String by extra("0.3.1-dev-RC")
allprojects {
group = "center.sciprog"
version = "0.2.2"
group = "space.kscience"
version = "0.4.0-dev-7"
repositories {
mavenLocal()
maven("https://repo.kotlin.link")
maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
}
}
@@ -24,20 +20,12 @@ ksciencePublish {
useApache2Licence()
useSPCTeam()
}
github("SciProgCentre", "maps-kt")
space(
if (isInDevelopment) {
"https://maven.pkg.jetbrains.space/spc/p/sci/dev"
} else {
"https://maven.pkg.jetbrains.space/spc/p/sci/maven"
}
)
sonatype()
repository("spc","https://maven.sciprog.center/kscience")
sonatype("https://oss.sonatype.org")
}
subprojects {
repositories {
maven("https://maven.pkg.jetbrains.space/mipt-npm/p/sci/dev")
google()
mavenCentral()
maven("https://repo.kotlin.link")
@@ -48,4 +36,3 @@ subprojects {
readme.readmeTemplate = file("docs/templates/README-TEMPLATE.md")

4
demo/maps-wasm/README.md Normal file
View File

@@ -0,0 +1,4 @@
# Module maps-wasm

View File

@@ -0,0 +1,38 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
kotlin("multiplatform")
alias(spclibs.plugins.compose.compiler)
alias(spclibs.plugins.compose.jb)
}
//val ktorVersion: String by rootProject.extra
kotlin {
wasmJs {
browser()
binaries.executable()
}
sourceSets {
commonMain {
dependencies {
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
api(compose.components.resources)
}
}
wasmJsMain {
dependencies {
implementation(projects.mapsKtScheme)
}
}
}
}
compose {
web {
}
}

View File

Before

Width:  |  Height:  |  Size: 469 KiB

After

Width:  |  Height:  |  Size: 469 KiB

View File

@@ -0,0 +1,82 @@
@file:OptIn(ExperimentalResourceApi::class, ExperimentalComposeUiApi::class)
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.window.CanvasBasedWindow
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.painterResource
import space.kscience.kmath.geometry.Angle
import space.kscience.maps.features.FeatureStore
import space.kscience.maps.features.ViewConfig
import space.kscience.maps.features.ViewPoint
import space.kscience.maps.features.color
import space.kscience.maps.scheme.*
import space.kscience.maps_wasm.generated.resources.Res
import space.kscience.maps_wasm.generated.resources.middle_earth
@Composable
fun App() {
val scope = rememberCoroutineScope()
val features = FeatureStore.remember(XYCoordinateSpace) {
background(1600f, 1200f) {
painterResource(Res.drawable.middle_earth)
}
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)
arc(center = 1132.0881 to 394.99127, radius = 20f, startAngle = Angle.zero, Angle.piTimes2)
//circle(410.52737 to 868.7676, id = "hobbit")
scope.launch {
var t = 0.0
while (isActive) {
val x = 410.52737 + t * (1132.0881 - 410.52737)
val y = 868.7676 + t * (394.99127 - 868.7676)
circle(x to y, id = "hobbit").color(Color.Green)
delay(100)
t += 0.005
if (t >= 1.0) t = 0.0
}
}
}
val initialViewPoint: ViewPoint<XY> = remember {
features.getBoundingBox(1f)?.computeViewPoint() ?: XYViewPoint(XY(0f, 0f))
}
var viewPoint: ViewPoint<XY> by remember { mutableStateOf(initialViewPoint) }
val mapState: XYCanvasState = XYCanvasState.remember(
ViewConfig(
onClick = { _, click ->
println("${click.focus.x}, ${click.focus.y}")
},
onViewChange = { viewPoint = this }
),
initialViewPoint = initialViewPoint,
)
SchemeView(
mapState,
features,
)
}
fun main() {
// renderComposable(rootElementId = "root") {
CanvasBasedWindow("Maps demo", canvasElementId = "ComposeTarget") {
App()
}
}

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Compose App</title>
<script type="application/javascript" src="skiko.js"></script>
<script type="application/javascript" src="maps-wasm.js"></script>
</head>
<body>
<canvas id="ComposeTarget"></canvas>
</body>
</html>

View File

@@ -2,22 +2,35 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
alias(spclibs.plugins.compose.compiler)
alias(spclibs.plugins.compose.jb)
}
val ktorVersion: String by rootProject.extra
repositories {
maven("https://repo.osgeo.org/repository/release/")
exclusiveContent {
forRepository {
maven("https://repo.osgeo.org/repository/release/")
}
filter {
includeGroup("javax.media")
}
}
}
kotlin {
jvmToolchain(11)
jvmToolchain(17)
jvm()
sourceSets {
val jvmMain by getting {
dependencies {
implementation(projects.mapsKtCompose)
implementation(projects.mapsKtGeojson)
implementation(projects.mapsKtGeotools)
implementation(compose.desktop.currentOs)
implementation("io.ktor:ktor-client-cio")
implementation("ch.qos.logback:logback-classic:1.2.11")
implementation(spclibs.logback.classic)
}
}
val jvmTest by getting

View File

@@ -13,11 +13,7 @@ import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import center.sciprog.attributes.Attributes
import center.sciprog.maps.compose.*
import center.sciprog.maps.coordinates.*
import center.sciprog.maps.features.*
import center.sciprog.maps.geojson.geoJson
import center.sciprog.maps.geotools.geoTiff
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.delay
@@ -27,15 +23,22 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import space.kscience.attributes.Attributes
import space.kscience.kmath.geometry.Angle
import space.kscience.kmath.geometry.degrees
import space.kscience.kmath.geometry.radians
import space.kscience.maps.compose.*
import space.kscience.maps.coordinates.GeodeticMapCoordinates
import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.coordinates.kilometers
import space.kscience.maps.features.*
import space.kscience.maps.geojson.geoJson
import java.nio.file.Path
import kotlin.math.PI
import kotlin.random.Random
public fun GeodeticMapCoordinates.toShortString(): String =
"${(latitude.degrees).toString().take(6)}:${(longitude.degrees).toString().take(6)}"
"${(latitude.toDegrees().value).toString().take(6)}:${(longitude.toDegrees().value).toString().take(6)}"
@OptIn(ExperimentalFoundationApi::class)
@@ -55,7 +58,6 @@ fun App() {
val centerCoordinates = MutableStateFlow<Gmc?>(null)
val pointOne = 55.568548 to 37.568604
val pointTwo = 55.929444 to 37.518434
// val pointThree = 60.929444 to 37.518434
@@ -71,14 +73,21 @@ fun App() {
) {
geoJson(javaClass.getResource("/moscow.geo.json")!!)
.modifyAttribute(ColorAttribute, Color.Blue)
.modifyAttribute(AlphaAttribute, 0.4f)
.color(Color.Blue)
.alpha(0.4f)
geoTiff(geoTiffStream = { javaClass.getResourceAsStream("/wind_direction.tif")!! })
.alpha(0.2f)
icon(pointOne, Icons.Filled.Home)
val marker1 = rectangle(55.744 to 38.614, size = DpSize(10.dp, 10.dp)).color(Color.Magenta)
val marker2 = rectangle(55.8 to 38.5, size = DpSize(10.dp, 10.dp)).color(Color.Magenta)
val marker3 = rectangle(56.0 to 38.5, size = DpSize(10.dp, 10.dp)).color(Color.Magenta)
val marker1 = rectangle(55.744 to 38.614, size = DpSize(10.dp, 10.dp))
.color(Color.Magenta)
val marker2 = rectangle(55.8 to 38.5, size = DpSize(10.dp, 10.dp))
.color(Color.Magenta)
val marker3 = rectangle(56.0 to 38.5, size = DpSize(10.dp, 10.dp))
.color(Color.Magenta)
draggableLine(marker1, marker2, id = "line 1").color(Color.Red).onClick {
println("line 1 clicked")
@@ -90,6 +99,7 @@ fun App() {
println("line 3 clicked")
}
multiLine(
points = listOf(
55.742465 to 37.615812,
@@ -101,7 +111,19 @@ fun App() {
),
)
//remember feature ID
// points(
// points = listOf(
// 55.744 to 38.614,
// 55.8 to 38.5,
// 56.0 to 38.5,
// )
// ).pointSize(5f)
// geodeticLine(Gmc.ofDegrees(40.7128, -74.0060), Gmc.ofDegrees(55.742465, 37.615812)).color(Color.Blue)
// line(Gmc.ofDegrees(40.7128, -74.0060), Gmc.ofDegrees(55.742465, 37.615812))
//remember feature ref
val circleId = circle(
centerCoordinates = pointTwo,
)
@@ -120,9 +142,11 @@ fun App() {
arc(pointOne, 10.0.kilometers, (PI / 4).radians, -Angle.pi / 2)
line(pointOne, pointTwo, id = "line")
text(pointOne, "Home", font = { size = 32f })
pixelMap(
space.Rectangle(
Gmc(latitude = 55.58461879539754.degrees, longitude = 37.8746197303493.degrees),
@@ -132,12 +156,14 @@ fun App() {
0.005.degrees
) { gmc ->
Color(
red = ((gmc.latitude + Angle.piDiv2).degrees*10 % 1f).toFloat(),
green = ((gmc.longitude + Angle.pi).degrees*10 % 1f).toFloat(),
blue = 0f
).copy(alpha = 0.3f)
red = ((gmc.latitude + Angle.piDiv2).toDegrees().value * 10 % 1f).toFloat(),
green = ((gmc.longitude + Angle.pi).toDegrees().value * 10 % 1f).toFloat(),
blue = 0f,
alpha = 0.3f
)
}
centerCoordinates.filterNotNull().onEach {
group(id = "center") {
circle(center = it, id = "circle", size = 1.dp).color(Color.Blue)
@@ -146,19 +172,20 @@ fun App() {
}.launchIn(scope)
//Add click listeners for all polygons
forEachWithType<Gmc, PolygonFeature<Gmc>> { ref ->
forEachWithType<Gmc, PolygonFeature<Gmc>> { ref, polygon: PolygonFeature<Gmc> ->
ref.onClick(PointerMatcher.Primary) {
println("Click on ${ref.id}")
println("Click on $ref")
//draw in top-level scope
with(this@MapView) {
multiLine(
ref.resolve().points,
polygon.points,
attributes = Attributes(ZAttribute, 10f),
id = "selected",
).modifyAttribute(StrokeAttribute, 4f).color(Color.Magenta)
}
}
}
// println(toPrettyString())
}
}
}

Binary file not shown.

View File

@@ -2,14 +2,15 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
alias(spclibs.plugins.compose.compiler)
alias(spclibs.plugins.compose.jb)
}
val ktorVersion: String by rootProject.extra
kotlin {
jvm()
jvmToolchain(11)
jvmToolchain(17)
sourceSets {
val jvmMain by getting {
dependencies {

View File

@@ -9,11 +9,11 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import center.sciprog.maps.features.*
import center.sciprog.maps.scheme.SchemeView
import center.sciprog.maps.scheme.XY
import center.sciprog.maps.scheme.XYCoordinateSpace
import center.sciprog.maps.scheme.XYViewScope
import space.kscience.maps.features.*
import space.kscience.maps.scheme.SchemeView
import space.kscience.maps.scheme.XY
import space.kscience.maps.scheme.XYCanvasState
import space.kscience.maps.scheme.XYCoordinateSpace
@Composable
@Preview
@@ -24,14 +24,14 @@ fun App() {
val myPolygon: SnapshotStateList<XY> = remember { mutableStateListOf<XY>() }
val featureState: FeatureGroup<XY> = FeatureGroup.remember(XYCoordinateSpace) {
val featureState = FeatureStore.remember(XYCoordinateSpace) {
multiLine(
listOf(XY(0f, 0f), XY(0f, 1f), XY(1f, 1f), XY(1f, 0f), XY(0f, 0f)),
id = "frame"
)
}
val mapState: XYViewScope = XYViewScope.remember(
val mapState: XYCanvasState = XYCanvasState.remember(
config = ViewConfig<XY>(
onClick = { event, point ->
if (event.buttons.isSecondaryPressed) {
@@ -55,6 +55,7 @@ fun App() {
}
draggableMultiLine(
pointRefs + pointRefs.first(),
"line"
)
}
}

View File

@@ -2,30 +2,35 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
alias(spclibs.plugins.compose.compiler)
alias(spclibs.plugins.compose.jb)
}
val ktorVersion: String by rootProject.extra
kotlin {
jvm()
jvmToolchain(11)
jvmToolchain(17)
sourceSets {
val jvmMain by getting {
commonMain {
dependencies {
implementation(projects.mapsKtScheme)
implementation(compose.desktop.currentOs)
implementation("ch.qos.logback:logback-classic:1.2.11")
implementation(compose.components.resources)
}
}
jvmMain{
dependencies {
implementation(projects.mapsKtScheme)
implementation(spclibs.logback.classic)
}
}
val jvmTest by getting
}
}
compose{
compose {
desktop {
application {
mainClass = "MainKt"
//mainClass = "Joker2023Kt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "scheme-compose-demo"
@@ -33,4 +38,8 @@ compose{
}
}
}
resources {
generateResClass = always
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

View File

@@ -4,22 +4,22 @@ import androidx.compose.foundation.ContextMenuArea
import androidx.compose.foundation.ContextMenuItem
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import center.sciprog.maps.features.FeatureGroup
import center.sciprog.maps.features.ViewConfig
import center.sciprog.maps.features.ViewPoint
import center.sciprog.maps.features.color
import center.sciprog.maps.scheme.*
import center.sciprog.maps.svg.FeatureStateSnapshot
import center.sciprog.maps.svg.exportToSvg
import center.sciprog.maps.svg.snapshot
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import space.kscience.kmath.geometry.Angle
import space.kscience.maps.features.*
import space.kscience.maps.scheme.*
import space.kscience.maps.svg.exportToPng
import space.kscience.maps.svg.exportToSvg
import space.kscience.scheme.generated.resources.Res
import space.kscience.scheme.generated.resources.middle_earth
import java.awt.Desktop
import java.nio.file.Files
@@ -29,8 +29,8 @@ fun App() {
MaterialTheme {
val scope = rememberCoroutineScope()
val schemeFeaturesState: FeatureGroup<XY> = FeatureGroup.remember(XYCoordinateSpace) {
background(1600f, 1200f) { painterResource("middle-earth.jpg") }
val features = FeatureStore.remember(XYCoordinateSpace) {
background(1600f, 1200f) { painterResource(Res.drawable.middle_earth) }
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)
@@ -53,32 +53,40 @@ fun App() {
}
val initialViewPoint: ViewPoint<XY> = remember {
schemeFeaturesState.getBoundingBox(1f)?.computeViewPoint() ?: XYViewPoint(XY(0f, 0f))
features.getBoundingBox(1f)?.computeViewPoint() ?: XYViewPoint(XY(0f, 0f))
}
var viewPoint: ViewPoint<XY> by remember { mutableStateOf(initialViewPoint) }
var snapshot: FeatureStateSnapshot<XY>? by remember { mutableStateOf(null) }
val painterCache = features.pointerCache()
if (snapshot == null) {
snapshot = schemeFeaturesState.snapshot()
}
val textMeasurer = rememberTextMeasurer()
ContextMenuArea(
items = {
listOf(
ContextMenuItem("Export to SVG") {
snapshot?.let {
val path = Files.createTempFile("scheme-kt-", ".svg")
it.exportToSvg(viewPoint, 800.0, 800.0, path)
println(path.toFile())
Desktop.getDesktop().browse(path.toFile().toURI())
}
val path = Files.createTempFile("scheme-kt-", ".svg")
features.exportToSvg(viewPoint, painterCache, Size(800f, 800f), path)
println(path.toFile())
Desktop.getDesktop().browse(path.toFile().toURI())
},
ContextMenuItem("Export to PNG") {
val path = Files.createTempFile("scheme-kt-", ".png")
features.exportToPng(
viewPoint,
painterCache,
textMeasurer,
Size(800f, 800f),
path
)
println(path.toFile())
Desktop.getDesktop().browse(path.toFile().toURI())
}
)
}
) {
val mapState: XYViewScope = XYViewScope.remember(
val mapState: XYCanvasState = XYCanvasState.remember(
ViewConfig(
onClick = { _, click ->
println("${click.focus.x}, ${click.focus.y}")
@@ -90,7 +98,7 @@ fun App() {
SchemeView(
mapState,
schemeFeaturesState,
features,
)
}

View File

@@ -0,0 +1,83 @@
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Face
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import org.jetbrains.compose.resources.painterResource
import space.kscience.maps.features.*
import space.kscience.maps.scheme.*
import space.kscience.maps.scheme.XYCoordinateSpace.Rectangle
import space.kscience.scheme.generated.resources.Res
import space.kscience.scheme.generated.resources.SPC_logo
import space.kscience.scheme.generated.resources.joker2023
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Joker2023 demo",
icon = painterResource(Res.drawable.SPC_logo)
) {
MaterialTheme {
SchemeView(
initialRectangle = Rectangle(XY(0f, 0f), XY(1734f, 724f)),
config = ViewConfig(
onClick = { _, pointer ->
println("(${pointer.focus.x}, ${pointer.focus.y})")
}
)
) {
background(1734f, 724f, id = "background") { painterResource(Res.drawable.joker2023) }
group(id = "hall_1") {
polygon(
listOf(
XY(1582.0042, 210.29636),
XY(1433.7021, 127.79796),
XY(1370.7639, 127.79796),
XY(1315.293, 222.73865),
XY(1314.2262, 476.625),
XY(1364.3635, 570.4984),
XY(1434.7689, 570.4984),
XY(1579.8469, 493.69244),
)
).modifyAttributes {
ColorAttribute(Color.Blue)
AlphaAttribute(0.4f)
}.onClick {
println("hall_1")
}
}
group(id = "hall_2") {
rectanglePolygon(
left = 893, right = 1103,
bottom = 223, top = 406,
).modifyAttributes {
ColorAttribute(Color.Blue)
AlphaAttribute(0.4f)
}
}
group(id = "hall_3") {
rectanglePolygon(
Rectangle(XY(460f, 374f), width = 140f, height = 122f),
).modifyAttributes {
ColorAttribute(Color.Blue)
AlphaAttribute(0.4f)
}
}
group(id = "people") {
icon(XY(815.60535, 342.71313), Icons.Default.Face).color(Color.Red)
icon(XY(743.751, 381.09064), Icons.Default.Face).color(Color.Red)
icon(XY(1349.6648, 417.36014), Icons.Default.Face).color(Color.Red)
icon(XY(1362.4658, 287.21667), Icons.Default.Face).color(Color.Red)
icon(XY(208.24274, 317.08566), Icons.Default.Face).color(Color.Red)
icon(XY(293.5827, 319.21915), Icons.Default.Face).color(Color.Red)
}
}
}
}
}

View File

@@ -1,13 +1,14 @@
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
alias(spclibs.plugins.compose.compiler)
alias(spclibs.plugins.compose.jb)
}
val ktorVersion: String by rootProject.extra
kotlin {
jvm()
jvmToolchain(11)
jvmToolchain(17)
sourceSets {
val jvmMain by getting {
dependencies {

View File

@@ -8,21 +8,21 @@ import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import center.sciprog.maps.features.*
import center.sciprog.maps.scheme.SchemeView
import center.sciprog.maps.scheme.XY
import space.kscience.kmath.geometry.Angle
import space.kscience.kmath.geometry.Circle2D
import space.kscience.kmath.geometry.DoubleVector2D
import space.kscience.kmath.geometry.Euclidean2DSpace
import space.kscience.kmath.geometry.Vector2D
import space.kscience.kmath.geometry.euclidean2d.Circle2D
import space.kscience.kmath.geometry.euclidean2d.Float64Space2D
import space.kscience.maps.features.*
import space.kscience.maps.scheme.SchemeView
import space.kscience.maps.scheme.XY
import space.kscience.trajectory.*
import kotlin.random.Random
private fun DoubleVector2D.toXY() = XY(x.toFloat(), y.toFloat())
private fun Vector2D<out Number>.toXY() = XY(x.toFloat(), y.toFloat())
private val random = Random(123)
fun FeatureGroup<XY>.trajectory(
fun FeatureBuilder<XY>.trajectory(
trajectory: Trajectory2D,
colorPicker: (Trajectory2D) -> Color = { Color.Blue },
): FeatureRef<XY, FeatureGroup<XY>> = group {
@@ -32,7 +32,7 @@ fun FeatureGroup<XY>.trajectory(
bCoordinates = trajectory.end.toXY(),
).color(colorPicker(trajectory))
is CircleTrajectory2D -> with(Euclidean2DSpace) {
is CircleTrajectory2D -> with(Float64Space2D) {
val topLeft = trajectory.circle.center + vector(-trajectory.circle.radius, trajectory.circle.radius)
val bottomRight = trajectory.circle.center + vector(trajectory.circle.radius, -trajectory.circle.radius)
@@ -54,25 +54,25 @@ fun FeatureGroup<XY>.trajectory(
}
}
fun FeatureGroup<XY>.obstacle(obstacle: Obstacle, colorPicker: (Trajectory2D) -> Color = { Color.Red }) {
fun FeatureBuilder<XY>.obstacle(obstacle: Obstacle, colorPicker: (Trajectory2D) -> Color = { Color.Red }) {
trajectory(obstacle.circumvention, colorPicker)
polygon(obstacle.arcs.map { it.center.toXY() }).color(Color.Gray)
}
fun FeatureGroup<XY>.pose(pose2D: Pose2D) = with(Euclidean2DSpace) {
fun FeatureBuilder<XY>.pose(pose2D: Pose2D) = with(Float64Space2D) {
line(pose2D.toXY(), (pose2D + Pose2D.bearingToVector(pose2D.bearing)).toXY())
}
@Composable
@Preview
fun closePoints() {
fun closePoints() = with(Float64Space2D){
SchemeView {
val obstacle = Obstacle(
Circle2D(Euclidean2DSpace.vector(0.0, 0.0), 1.0),
Circle2D(Euclidean2DSpace.vector(0.0, 1.0), 1.0),
Circle2D(Euclidean2DSpace.vector(1.0, 1.0), 1.0),
Circle2D(Euclidean2DSpace.vector(1.0, 0.0), 1.0)
Circle2D(vector(0.0, 0.0), 1.0),
Circle2D(vector(0.0, 1.0), 1.0),
Circle2D(vector(1.0, 1.0), 1.0),
Circle2D(vector(1.0, 0.0), 1.0)
)
val enter = Pose2D(-0.8, -0.8, Angle.pi)
val exit = Pose2D(-0.8, -0.8, Angle.piDiv2)
@@ -101,7 +101,7 @@ fun closePoints() {
@Preview
fun singleObstacle() {
SchemeView {
val obstacle = Obstacle(Circle2D(Euclidean2DSpace.vector(7.0, 1.0), 5.0))
val obstacle = Obstacle(Circle2D(Float64Space2D.vector(7.0, 1.0), 5.0))
val enter = Pose2D(-5, -1, Angle.pi / 4)
val exit = Pose2D(20, 4, Angle.pi * 3 / 4)
@@ -123,19 +123,19 @@ fun singleObstacle() {
@Composable
@Preview
fun doubleObstacle() {
fun doubleObstacle() = with(Float64Space2D){
SchemeView {
val obstacles = arrayOf(
Obstacle(
Circle2D(Euclidean2DSpace.vector(1.0, 6.5), 0.5),
Circle2D(Euclidean2DSpace.vector(2.0, 1.0), 0.5),
Circle2D(Euclidean2DSpace.vector(6.0, 0.0), 0.5),
Circle2D(Euclidean2DSpace.vector(5.0, 5.0), 0.5)
Circle2D(vector(1.0, 6.5), 0.5),
Circle2D(vector(2.0, 1.0), 0.5),
Circle2D(vector(6.0, 0.0), 0.5),
Circle2D(vector(5.0, 5.0), 0.5)
), Obstacle(
Circle2D(Euclidean2DSpace.vector(10.0, 1.0), 0.5),
Circle2D(Euclidean2DSpace.vector(16.0, 0.0), 0.5),
Circle2D(Euclidean2DSpace.vector(14.0, 6.0), 0.5),
Circle2D(Euclidean2DSpace.vector(9.0, 4.0), 0.5)
Circle2D(vector(10.0, 1.0), 0.5),
Circle2D(vector(16.0, 0.0), 0.5),
Circle2D(vector(14.0, 6.0), 0.5),
Circle2D(vector(9.0, 4.0), 0.5)
)
)
@@ -157,6 +157,14 @@ fun doubleObstacle() {
}
}
@Composable
@Preview
fun singleElement() {
SchemeView {
points(listOf(XY(1f,1f)))
}
}
@Composable
@Preview
@@ -165,6 +173,7 @@ fun playground() {
"Close starting points",
"Single obstacle",
"Two obstacles",
"Single element"
)
var currentExample by remember { mutableStateOf(examples.first()) }
@@ -182,6 +191,7 @@ fun playground() {
examples[0] -> closePoints()
examples[1] -> singleObstacle()
examples[2] -> doubleObstacle()
examples[3] -> singleElement()
}
}
}

View File

@@ -1,17 +0,0 @@
## Artifact:
The Maven coordinates of this project are `${group}:${name}:${version}`.
**Gradle Kotlin DSL:**
```kotlin
repositories {
maven("https://repo.kotlin.link")
mavenCentral()
// development and snapshot versions
maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
}
dependencies {
implementation("${group}:${name}:${version}")
}
```

View File

@@ -1,9 +1,27 @@
# Maps-kt
This repository is a work-in-progress implementation of Map-with-markers component for Compose-Multiplatform
A Kotlin Multiplatform library for interactive maps and geospatial data visualization using Compose Multiplatform.
![](docs/images/Screenshot%202023-01-12%20110429.png)
## Overview
Maps-kt provides a comprehensive set of tools for working with maps, geospatial data, and cartographic projections in Kotlin. It offers a UI-agnostic core with Compose Multiplatform implementations, allowing you to create interactive maps with markers, layers, and custom visualizations across multiple platforms.
## Features
- **Multiplatform Support**: Works on JVM, JavaScript, Native, and WebAssembly platforms
- **Compose Integration**: Seamless integration with Compose Multiplatform for modern UI development
- **Map Projections**: Support for Mercator, Web Mercator, and other cartographic projections
- **Geospatial Data**: Tools for working with coordinates, distances, angles, and ellipsoid geometry
- **Tile Providers**: Integration with OpenStreetMap and other tile providers
- **GeoJSON Support**: Parse and visualize GeoJSON data
- **Path Optimization**: Trajectory and path optimization capabilities
## License
This project is licensed under the Apache 2.0 License.
## Modules
${modules}
${modules}

View File

@@ -1,10 +1,5 @@
kotlin.code.style=official
compose.version=1.4.0
agp.version=7.4.2
android.useAndroidX=true
org.jetbrains.compose.experimental.jscanvas.enabled=true
org.gradle.jvmargs=-Xmx4096m
toolsVersion=0.14.6-kotlin-1.8.20
toolsVersion=0.17.1-kotlin-2.1.20

13
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,13 @@
[versions]
kmath = "0.4.2"
geotools = "32.2"
attributes = "0.3.0"
[libraries]
kmath-geometry = { module = "space.kscience:kmath-geometry", version.ref = "kmath" }
gt-geotiff = { module = "org.geotools:gt-geotiff", version.ref = "geotools" }
gt-shapefile = { module = "org.geotools:gt-shapefile", version.ref = "geotools" }
gt-epsg-hsql = { module = "org.geotools:gt-epsg-hsql", version.ref = "geotools" }
attributes-serialization = { module = "space.kscience:attributes-kt-serialization", version.ref = "attributes" }

View File

@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

0
gradlew vendored Normal file → Executable file
View File

View File

@@ -7,19 +7,8 @@ The core interfaces of KMath.
## Artifact:
The Maven coordinates of this project are `center.sciprog:maps-kt-compose:0.2.2`.
The Maven coordinates of this project are `space.kscience:maps-kt-compose:0.4.0-dev-7`.
**Gradle Groovy:**
```groovy
repositories {
maven { url 'https://repo.kotlin.link' }
mavenCentral()
}
dependencies {
implementation 'center.sciprog:maps-kt-compose:0.2.2'
}
```
**Gradle Kotlin DSL:**
```kotlin
repositories {
@@ -28,6 +17,6 @@ repositories {
}
dependencies {
implementation("center.sciprog:maps-kt-compose:0.2.2")
implementation("space.kscience:maps-kt-compose:0.4.0-dev-7")
}
```

View File

@@ -1,43 +1,40 @@
plugins {
id("space.kscience.gradle.mpp")
id("org.jetbrains.compose")
alias(spclibs.plugins.compose.compiler)
alias(spclibs.plugins.compose.jb)
// id("com.android.library")
`maven-publish`
}
kscience{
kscience {
jvm()
}
wasm()
kotlin {
sourceSets {
commonMain {
dependencies {
api(projects.mapsKtCore)
api(projects.mapsKtFeatures)
api(compose.foundation)
api(project.dependencies.platform(spclibs.ktor.bom))
api("io.ktor:ktor-client-core")
api("io.github.microutils:kotlin-logging:2.1.23")
}
}
val jvmTest by getting {
dependencies {
implementation("io.ktor:ktor-client-cio")
implementation(compose.desktop.currentOs)
implementation(spclibs.kotlinx.coroutines.test)
useCoroutines()
implementation(spclibs.logback.classic)
}
}
commonMain{
api(projects.mapsKtCore)
api(projects.mapsKtFeatures)
api(dependencies.platform(spclibs.ktor.bom))
api(compose.foundation)
}
jvmMain{
api("io.ktor:ktor-client-cio")
}
jvmTest{
implementation(spclibs.kotlinx.coroutines.test)
implementation(spclibs.logback.classic)
implementation(compose.desktop.currentOs)
}
}
readme {
description = "Compose-multiplaform implementation for web-mercator tiled maps"
maturity = space.kscience.gradle.Maturity.EXPERIMENTAL
propertyByTemplate("artifact", rootProject.file("docs/templates/ARTIFACT-TEMPLATE.md"))
maturity = space.kscience.gradle.Maturity.DEVELOPMENT
feature(
id = "osm",
) { "OpenStreetMap tile provider." }
}
}

View File

@@ -1,70 +0,0 @@
package center.sciprog.maps.compose
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.features.*
@Composable
public expect fun MapView(
viewScope: MapViewScope,
features: FeatureGroup<Gmc>,
modifier: Modifier = Modifier.fillMaxSize(),
)
/**
* A builder for a Map with static features.
*/
@Composable
public fun MapView(
mapTileProvider: MapTileProvider,
features: FeatureGroup<Gmc>,
initialViewPoint: ViewPoint<Gmc>? = null,
initialRectangle: Rectangle<Gmc>? = null,
config: ViewConfig<Gmc> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
) {
val mapState: MapViewScope = MapViewScope.remember(
mapTileProvider,
config,
initialViewPoint = initialViewPoint,
initialRectangle = initialRectangle ?: features.getBoundingBox(Float.MAX_VALUE),
)
MapView(mapState, features, modifier)
}
/**
* Draw a map using convenient parameters. If neither [initialViewPoint], noe [initialRectangle] is defined,
* use map features to infer view region.
* @param initialViewPoint The view point of the map using center and zoom. Is used if provided
* @param initialRectangle The rectangle to be used for view point computation. Used if [initialViewPoint] is not defined.
* @param buildFeatures - a builder for features
*/
@Composable
public fun MapView(
mapTileProvider: MapTileProvider,
initialViewPoint: ViewPoint<Gmc>? = null,
initialRectangle: Rectangle<Gmc>? = null,
config: ViewConfig<Gmc> = ViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: FeatureGroup<Gmc>.() -> Unit = {},
) {
val featureState = FeatureGroup.remember(WebMercatorSpace, buildFeatures)
val mapState: MapViewScope = MapViewScope.remember(
mapTileProvider,
config,
initialViewPoint = initialViewPoint,
initialRectangle = initialRectangle ?: featureState.features.computeBoundingBox(
WebMercatorSpace,
Float.MAX_VALUE
),
)
MapView(mapState, featureState, modifier)
}

View File

@@ -1,143 +0,0 @@
package center.sciprog.maps.compose
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
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.Gmc
import center.sciprog.maps.coordinates.GmcCurve
import center.sciprog.maps.features.*
import space.kscience.kmath.geometry.Angle
import kotlin.math.ceil
internal fun FeatureGroup<Gmc>.coordinatesOf(pair: Pair<Number, Number>) =
GeodeticMapCoordinates.ofDegrees(pair.first.toDouble(), pair.second.toDouble())
public typealias MapFeature = Feature<Gmc>
public fun FeatureGroup<Gmc>.circle(
centerCoordinates: Pair<Number, Number>,
size: Dp = 5.dp,
id: String? = null,
): FeatureRef<Gmc, CircleFeature<Gmc>> = feature(
id, CircleFeature(space, coordinatesOf(centerCoordinates), size)
)
public fun FeatureGroup<Gmc>.rectangle(
centerCoordinates: Pair<Number, Number>,
size: DpSize = DpSize(5.dp, 5.dp),
id: String? = null,
): FeatureRef<Gmc, RectangleFeature<Gmc>> = feature(
id, RectangleFeature(space, coordinatesOf(centerCoordinates), size)
)
public fun FeatureGroup<Gmc>.draw(
position: Pair<Number, Number>,
id: String? = null,
draw: DrawScope.() -> Unit,
): FeatureRef<Gmc, DrawFeature<Gmc>> = feature(
id,
DrawFeature(space, coordinatesOf(position), drawFeature = draw)
)
public fun FeatureGroup<Gmc>.line(
curve: GmcCurve,
id: String? = null,
): FeatureRef<Gmc, LineFeature<Gmc>> = feature(
id,
LineFeature(space, curve.forward.coordinates, curve.backward.coordinates)
)
public fun FeatureGroup<Gmc>.line(
aCoordinates: Pair<Double, Double>,
bCoordinates: Pair<Double, Double>,
id: String? = null,
): FeatureRef<Gmc, LineFeature<Gmc>> = feature(
id,
LineFeature(space, coordinatesOf(aCoordinates), coordinatesOf(bCoordinates))
)
public fun FeatureGroup<Gmc>.arc(
center: Pair<Double, Double>,
radius: Distance,
startAngle: Angle,
arcLength: Angle,
id: String? = null,
): FeatureRef<Gmc, ArcFeature<Gmc>> = feature(
id,
ArcFeature(
space,
oval = space.Rectangle(coordinatesOf(center), radius, radius),
startAngle = startAngle,
arcLength = arcLength,
)
)
public fun FeatureGroup<Gmc>.points(
points: List<Pair<Double, Double>>,
id: String? = null,
): FeatureRef<Gmc, PointsFeature<Gmc>> = feature(id, PointsFeature(space, points.map(::coordinatesOf)))
public fun FeatureGroup<Gmc>.multiLine(
points: List<Pair<Double, Double>>,
id: String? = null,
): FeatureRef<Gmc, MultiLineFeature<Gmc>> = feature(id, MultiLineFeature(space, points.map(::coordinatesOf)))
public fun FeatureGroup<Gmc>.icon(
position: Pair<Double, Double>,
image: ImageVector,
size: DpSize = DpSize(20.dp, 20.dp),
id: String? = null,
): FeatureRef<Gmc, VectorIconFeature<Gmc>> = feature(
id,
VectorIconFeature(
space,
coordinatesOf(position),
size,
image,
)
)
public fun FeatureGroup<Gmc>.text(
position: Pair<Double, Double>,
text: String,
font: FeatureFont.() -> Unit = { size = 16f },
id: String? = null,
): FeatureRef<Gmc, TextFeature<Gmc>> = feature(
id,
TextFeature(space, coordinatesOf(position), text, fontConfig = font)
)
public fun FeatureGroup<Gmc>.pixelMap(
rectangle: Rectangle<Gmc>,
latitudeDelta: Angle,
longitudeDelta: Angle,
id: String? = null,
builder: (Gmc) -> Color?,
): FeatureRef<Gmc, PixelMapFeature<Gmc>> = feature(
id,
PixelMapFeature(
space,
rectangle,
Structure2D(
ceil(rectangle.longitudeDelta / latitudeDelta).toInt(),
ceil(rectangle.latitudeDelta / longitudeDelta).toInt()
) { (i, j) ->
val longitude = rectangle.left + longitudeDelta * i
val latitude = rectangle.bottom + latitudeDelta * j
builder(
Gmc(latitude, longitude)
)
}
)
)

View File

@@ -1,10 +1,10 @@
package center.sciprog.maps.compose
package space.kscience.maps.compose
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.features.Rectangle
import space.kscience.kmath.geometry.Angle
import space.kscience.kmath.geometry.abs
import space.kscience.maps.coordinates.GeodeticMapCoordinates
import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.features.Rectangle
internal fun Angle.isBetween(a: Angle, b: Angle) = this in a..b || this in b..a

View File

@@ -1,6 +1,6 @@
package center.sciprog.maps.compose
@file:Suppress("DEPRECATION")
import kotlin.jvm.Synchronized
package space.kscience.maps.compose
internal class LruCache<K, V>(
@@ -8,8 +8,7 @@ internal class LruCache<K, V>(
) {
private val cache = linkedMapOf<K, V>()
@Synchronized
fun put(key: K, value: V){
fun put(key: K, value: V) {
if (cache.size >= capacity) {
cache.remove(cache.iterator().next().key)
}
@@ -25,15 +24,12 @@ internal class LruCache<K, V>(
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

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.compose
package space.kscience.maps.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -6,18 +6,39 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.coordinates.MercatorProjection
import center.sciprog.maps.coordinates.WebMercatorCoordinates
import center.sciprog.maps.coordinates.WebMercatorProjection
import center.sciprog.maps.features.*
import space.kscience.kmath.geometry.radians
import space.kscience.maps.compose.MapCanvasState.Companion.remember
import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.coordinates.MercatorProjection
import space.kscience.maps.coordinates.WebMercatorCoordinates
import space.kscience.maps.coordinates.WebMercatorProjection
import space.kscience.maps.features.*
import kotlin.math.*
public class MapViewScope internal constructor(
public val mapTileProvider: MapTileProvider,
/**
* Represents the state of a map canvas, extending the functionality of [CanvasState] to handle
* map-specific operations.
*
* This class utilizes the Web Mercator projection for map rendering and provides utilities
* to manage zoom levels, convert coordinates, and track view states.
* It operates with
* geodetic map coordinates (GMC) and simplifies interaction with the map's coordinate space.
*
* The class is internal to prevent direct instantiation; use the [remember] function to
* create or get a [MapCanvasState] instance within a composable.
*
* @constructor
* Creates an instance of [MapCanvasState] with the given configuration and default tile size
* (used only for scale computation).
*
* @param config The configuration for view-related behaviors such as zoom, clicks, and canvas size changes.
* @param tileSize The tile size used to compute scale, defaulting to [MapTileProvider.DEFAULT_TILE_SIZE].
*/
public class MapCanvasState internal constructor(
config: ViewConfig<Gmc>,
) : CoordinateViewScope<Gmc>(config) {
public val tileSize: Int = MapTileProvider.DEFAULT_TILE_SIZE
) : CanvasState<Gmc>(config) {
override val space: CoordinateSpace<Gmc> get() = WebMercatorSpace
private val scaleFactor: Float
@@ -60,10 +81,10 @@ public class MapViewScope internal constructor(
override fun computeViewPoint(rectangle: Rectangle<Gmc>): ViewPoint<Gmc> {
val zoom = log2(
min(
canvasSize.width.value / rectangle.longitudeDelta.radians,
canvasSize.height.value / rectangle.latitudeDelta.radians
) * 2 * PI / mapTileProvider.tileSize
)
canvasSize.width.value / rectangle.longitudeDelta.toRadians().value,
canvasSize.height.value / rectangle.latitudeDelta.toRadians().value
) * 2 * PI / tileSize
).coerceIn(0.0..22.0)
return space.ViewPoint(rectangle.center, zoom.toFloat())
}
@@ -83,18 +104,18 @@ public class MapViewScope internal constructor(
public companion object {
@Composable
public fun remember(
mapTileProvider: MapTileProvider,
config: ViewConfig<Gmc> = ViewConfig(),
initialViewPoint: ViewPoint<Gmc>? = null,
initialRectangle: Rectangle<Gmc>? = null,
): MapViewScope = remember {
MapViewScope(mapTileProvider, config).also { mapState ->
tileSize: Int = MapTileProvider.DEFAULT_TILE_SIZE,
): MapCanvasState = remember {
MapCanvasState(config, tileSize).apply {
if (initialViewPoint != null) {
mapState.viewPoint = initialViewPoint
viewPoint = initialViewPoint
} else if (initialRectangle != null) {
mapState.viewPoint = mapState.computeViewPoint(initialRectangle)
viewPoint = computeViewPoint(initialRectangle)
}
}
}
}
}
}

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.compose
package space.kscience.maps.compose
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
@@ -16,6 +16,12 @@ public data class MapTile(
val image: Image,
)
/**
* Interface representing a provider for map tiles.
*
* This interface defines the contract for asynchronous loading of map tiles and
* utilities for mapping between tile indices and coordinates.
*/
public interface MapTileProvider {
public fun CoroutineScope.loadTileAsync(tileId: TileId): Deferred<MapTile>

View File

@@ -0,0 +1,176 @@
package space.kscience.maps.compose
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.jetbrains.skia.Image
import space.kscience.attributes.Attributes
import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.features.*
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.uuid.ExperimentalUuidApi
private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last)
private val logger = KotlinLogging.logger("MapView")
//private fun FeatureDrawScope<Gmc>.drawTiles(
// tileProvider: MapTileProvider
//) {
//
//}
/**
* A component that renders map and provides basic map manipulation capabilities
*/
@OptIn(ExperimentalUuidApi::class)
@Composable
public fun MapView(
mapState: MapCanvasState,
featureStore: FeatureStore<Gmc>,
modifier: Modifier,
): Unit = with(mapState) {
val tileFeatures by featureStore.featureFlow
.map { it.values.filterIsInstance<TileFeature>() }
.collectAsState(emptyList())
val allTiles: Map<TileFeature, SnapshotStateMap<TileId, Image>> = remember(tileFeatures) {
tileFeatures.associateWith {
mutableStateMapOf()
}
}
LaunchedEffect(viewPoint, canvasSize, tileFeatures) {
allTiles.forEach { (tileFeature, tiles) ->
// Load tiles asynchronously
with(tileFeature.tileProvider) {
val indexRange = 0 until 2.0.pow(intZoom).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)
for (j in verticalIndices) {
for (i in horizontalIndices) {
val id = TileId(intZoom, i, j)
launch {
try {
val tile = loadTileAsync(id).await()
tiles[tile.id] = tile.image
} catch (ex: Exception) {
//displaying the error is maps responsibility
if (ex !is CancellationException) {
logger.error(ex) { "Failed to load tile with id=$id" }
}
}
}
tiles.keys.filter {
it.zoom != intZoom || it.j !in verticalIndices || it.i !in horizontalIndices
}.forEach {
tiles.remove(it)
}
}
}
}
}
}
FeatureCanvas(mapState, featureStore.featureFlow, modifier = modifier.canvasControls(mapState, featureStore)) {
// draw custom features
val tileScale = mapState.tileScale
allTiles.forEach { (feature, tiles) ->
val tileProvider = feature.tileProvider
clipRect {
val tileSize = IntSize(
ceil((tileProvider.tileSize.dp * tileScale).toPx()).toInt(),
ceil((tileProvider.tileSize.dp * tileScale).toPx()).toInt()
)
tiles.forEach { (id, image) ->
//converting back from tile index to screen offset
val offset = IntOffset(
(mapState.canvasSize.width / 2 + (tileProvider.toCoordinate(id.i).dp - mapState.centerCoordinates.x.dp) * tileScale).roundToPx(),
(mapState.canvasSize.height / 2 + (tileProvider.toCoordinate(id.j).dp - mapState.centerCoordinates.y.dp) * tileScale).roundToPx()
)
drawImage(
image = image.toComposeImageBitmap(),
dstOffset = offset,
dstSize = tileSize
)
}
}
}
}
}
@Composable
public fun MapView(
mapState: MapCanvasState,
mapTileProvider: MapTileProvider,
featureStore: FeatureStore<Gmc>,
modifier: Modifier,
) {
//FIXME this function modifies arguments
featureStore.feature("map", TileFeature(mapState.space, mapTileProvider, Attributes.EMPTY))
MapView(mapState, featureStore, modifier)
}
/**
* Create a [MapView] with given [featureStore] group.
*/
@Composable
public fun MapView(
mapTileProvider: MapTileProvider,
config: ViewConfig<Gmc>,
featureStore: FeatureStore<Gmc>,
initialViewPoint: ViewPoint<Gmc>? = null,
initialRectangle: Rectangle<Gmc>? = null,
modifier: Modifier = Modifier.fillMaxSize(),
) {
val mapState = MapCanvasState.remember(config, initialViewPoint, initialRectangle)
MapView(mapState, mapTileProvider, featureStore, modifier)
}
/**
* Draw a map using convenient parameters. If neither [initialViewPoint], noe [initialRectangle] is defined,
* use map features to infer the view region.
* @param initialViewPoint The view point of the map using center and zoom. Is used if provided
* @param initialRectangle The rectangle to be used for view point computation. Used if [initialViewPoint] is not defined.
* @param buildFeatures - a builder for features
*/
@Composable
public fun MapView(
mapTileProvider: MapTileProvider,
config: ViewConfig<Gmc> = ViewConfig(),
initialViewPoint: ViewPoint<Gmc>? = null,
initialRectangle: Rectangle<Gmc>? = null,
modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: FeatureStore<Gmc>.() -> Unit = {},
) {
val featureState = FeatureStore.remember(WebMercatorSpace, buildFeatures)
val computedRectangle = initialRectangle ?: featureState.getBoundingBox()
MapView(mapTileProvider, config, featureState, initialViewPoint, computedRectangle, modifier)
}

View File

@@ -1,9 +1,9 @@
package center.sciprog.maps.compose
package space.kscience.maps.compose
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.coordinates.WebMercatorProjection
import center.sciprog.maps.features.ViewPoint
import space.kscience.maps.coordinates.GeodeticMapCoordinates
import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.coordinates.WebMercatorProjection
import space.kscience.maps.features.ViewPoint
/**
* Observable position on the map. Includes observation coordinate and [zoom] factor

View File

@@ -0,0 +1,49 @@
package space.kscience.maps.compose
import space.kscience.attributes.Attributes
import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.features.*
/**
* Represents a custom feature associated with a specific map tile provider.
*
* This feature is designed to handle map tiles using the specified [MapTileProvider]
* and includes functionality for defining and modifying its attributes and spatial
* characteristics.
*
* @property space Defines the coordinate space used for this feature, enabling
* manipulation and operations in the map/scheme coordinate context.
* @property tileProvider The provider responsible for asynchronous loading and
* management of map tiles.
* @property attributes A collection of attributes associated with this feature,
* which can be modified through the `withAttributes` method.
*/
public data class TileFeature(
override val space: CoordinateSpace<Gmc>,
public val tileProvider: MapTileProvider,
override val attributes: Attributes
) : CustomFeature<Gmc> {
override fun getBoundingBox(zoom: Float): Rectangle<Gmc>? = null
override fun withAttributes(modify: Attributes.() -> Attributes): Feature<Gmc> =
copy(attributes = modify(attributes))
}
/**
* Adds a tile-based feature to the builder using the specified tile provider and optional identifier.
*
* @param tileProvider The provider responsible for asynchronous loading and management of map tiles.
* @param id An optional string identifier for the feature. If null, a unique ID will be generated.
* @return A reference to the created tile-based feature.
*/
public fun FeatureBuilder<Gmc>.tiles(
tileProvider: MapTileProvider,
id: String? = null,
): FeatureRef<Gmc, TileFeature> = feature(
id,
TileFeature(
space,
tileProvider,
Attributes.EMPTY
)
)

View File

@@ -1,25 +1,33 @@
package center.sciprog.maps.compose
package space.kscience.maps.compose
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.*
import center.sciprog.maps.features.CoordinateSpace
import center.sciprog.maps.features.Rectangle
import center.sciprog.maps.features.ViewPoint
import space.kscience.kmath.geometry.Angle
import space.kscience.kmath.geometry.radians
import space.kscience.maps.coordinates.*
import space.kscience.maps.features.CoordinateSpace
import space.kscience.maps.features.Rectangle
import space.kscience.maps.features.ViewPoint
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.pow
/**
* An implementation of the `CoordinateSpace` interface for the Web Mercator Projection.
*
* This object provides functionality for manipulating geodetic map coordinates (Gmc),
* which includes operations like creating rectangles, computing offset and distance,
* moving viewpoints, zooming, and polygon containment checks.
*/
public object WebMercatorSpace : CoordinateSpace<Gmc> {
private fun intZoom(zoom: Float): Int = floor(zoom).toInt()
private fun tileScale(zoom: Float): Float = 2f.pow(zoom - floor(zoom))
override fun Rectangle(first: Gmc, second: Gmc): Rectangle<Gmc> = GmcRectangle(first, second)
override fun Rectangle(first: Gmc, second: Gmc): Rectangle<Gmc> =
space.kscience.maps.compose.GmcRectangle(first, second)
override fun Rectangle(center: Gmc, zoom: Float, size: DpSize): Rectangle<Gmc> {
val scale = WebMercatorProjection.scaleFactor(zoom)
@@ -62,7 +70,10 @@ public object WebMercatorSpace : CoordinateSpace<Gmc> {
val maxLat = maxOf { it.top }
val minLong = minOf { it.left }
val maxLong = maxOf { it.right }
return GmcRectangle(Gmc.normalized(minLat, minLong), Gmc.normalized(maxLat, maxLong))
return space.kscience.maps.compose.GmcRectangle(
Gmc.normalized(minLat, minLong),
Gmc.normalized(maxLat, maxLong)
)
}
override fun Collection<Gmc>.wrapPoints(): Rectangle<Gmc>? {
@@ -72,7 +83,10 @@ public object WebMercatorSpace : CoordinateSpace<Gmc> {
val maxLat = maxOf { it.latitude }
val minLong = minOf { it.longitude }
val maxLong = maxOf { it.longitude }
return GmcRectangle(Gmc.normalized(minLat, minLong), Gmc.normalized(maxLat, maxLong))
return space.kscience.maps.compose.GmcRectangle(
Gmc.normalized(minLat, minLong),
Gmc.normalized(maxLat, maxLong)
)
}
override fun Gmc.offsetTo(b: Gmc, zoom: Float): DpOffset {
@@ -119,6 +133,7 @@ public fun CoordinateSpace<Gmc>.Rectangle(
/**
* A quasi-square section.
*/
@Suppress("UnusedReceiverParameter")
public fun CoordinateSpace<Gmc>.Rectangle(
center: GeodeticMapCoordinates,
height: Angle,
@@ -132,5 +147,5 @@ public fun CoordinateSpace<Gmc>.Rectangle(
center.latitude + (height / 2),
center.longitude + (width / 2)
)
return GmcRectangle(a, b)
return space.kscience.maps.compose.GmcRectangle(a, b)
}

View File

@@ -0,0 +1,279 @@
package space.kscience.maps.compose
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import org.jetbrains.skia.Font
import space.kscience.kmath.geometry.Angle
import space.kscience.maps.coordinates.*
import space.kscience.maps.features.*
import kotlin.math.ceil
internal fun FeatureBuilder<Gmc>.coordinatesOf(pair: Pair<Number, Number>) =
GeodeticMapCoordinates.ofDegrees(pair.first.toDouble(), pair.second.toDouble())
public typealias MapFeature = Feature<Gmc>
/**
* Adds a circle feature to the feature builder.
*
* @param centerCoordinates The geodetic map coordinates (latitude, longitude) of the circle's center as a pair of numbers.
* @param size The radius of the circle as a Dp value. Defaults to 5.dp.
* @param id An optional unique identifier for the circle feature. If null, an ID is automatically generated.
* @return A reference to the created circle feature within the feature store.
*/
public fun FeatureBuilder<Gmc>.circle(
centerCoordinates: Pair<Number, Number>,
size: Dp = 5.dp,
id: String? = null,
): FeatureRef<Gmc, CircleFeature<Gmc>> = feature(
id, CircleFeature(space, coordinatesOf(centerCoordinates), size)
)
/**
* Adds a rectangular feature to the map with the specified center coordinates and size.
*
* @param centerCoordinates The center coordinates of the rectangle as a pair of latitude and longitude values.
* @param size The size of the rectangle, with a default value of 5.dp x 5.dp.
* @param id An optional identifier for the rectangle. If null, a unique ID is generated automatically.
* @return A reference to the created rectangle feature.
*/
public fun FeatureBuilder<Gmc>.rectangle(
centerCoordinates: Pair<Number, Number>,
size: DpSize = DpSize(5.dp, 5.dp),
id: String? = null,
): FeatureRef<Gmc, RectangleFeature<Gmc>> = feature(
id, RectangleFeature(space, coordinatesOf(centerCoordinates), size)
)
/**
* Draws a feature on the map at the specified position.
*
* @param position The geographical coordinates as a pair of numbers (latitude, longitude) where the feature will be drawn.
* @param id Optional identifier for the feature. If null, a unique identifier will be generated.
* @param draw The drawing logic defined within the scope of the [DrawScope].
* @return A reference to the newly created draw feature.
*/
public fun FeatureBuilder<Gmc>.draw(
position: Pair<Number, Number>,
id: String? = null,
draw: DrawScope.() -> Unit,
): FeatureRef<Gmc, DrawFeature<Gmc>> = feature(
id,
DrawFeature(space, coordinatesOf(position), drawFeature = draw)
)
/**
* Adds a line feature to the feature builder using the specified curve and optional identifier.
*
* @param curve the geodetic curve defining the start and end points of the line
* @param id the optional identifier for the line feature; if null, an identifier is automatically generated
* @return a reference to the created line feature
*/
public fun FeatureBuilder<Gmc>.line(
curve: GmcCurve,
id: String? = null,
): FeatureRef<Gmc, LineFeature<Gmc>> = feature(
id,
LineFeature(space, curve.forward.coordinates, curve.backward.coordinates)
)
/**
* A segmented geodetic curve
*/
public fun FeatureBuilder<Gmc>.geodeticLine(
curve: GmcCurve,
ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84,
maxLineDistance: Distance = 100.kilometers,
id: String? = null,
): FeatureRef<Gmc, Feature<Gmc>> = if (curve.distance < maxLineDistance) {
feature(
id,
LineFeature(space, curve.forward.coordinates, curve.backward.coordinates)
)
} else {
val segments = ceil(curve.distance / maxLineDistance).toInt()
val segmentSize = curve.distance / segments
val points = buildList<GmcPose> {
add(curve.forward)
repeat(segments) {
val segment = ellipsoid.curveInDirection(this.last(), segmentSize, 1e-2)
add(segment.backward)
}
}
multiLine(points.map { it.coordinates }, id = id)
}
/**
* Creates a segmented geodetic line between two geodetic coordinates. The line is calculated
* based on the given ellipsoid and segmented into smaller parts if its total length exceeds the
* specified maximum line segment distance.
*
* @param from The starting geodetic coordinate for the line.
* @param to The ending geodetic coordinate for the line.
* @param ellipsoid The reference ellipsoid used for calculations. Defaults to `GeoEllipsoid.WGS84`.
* @param maxLineDistance The maximum allowed distance for a single line segment. Defaults to 100 kilometers.
* @param id An optional unique identifier for the resulting feature. If null, a unique ID is generated.
* @return A reference to the created geodetic line feature.
*/
public fun FeatureBuilder<Gmc>.geodeticLine(
from: Gmc,
to: Gmc,
ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84,
maxLineDistance: Distance = 100.kilometers,
id: String? = null,
): FeatureRef<Gmc, Feature<Gmc>> = geodeticLine(ellipsoid.curveBetween(from, to), ellipsoid, maxLineDistance, id)
/**
* Creates a `LineFeature` with the specified start and end coordinates and adds it to the feature store.
*
* @param aCoordinates The coordinates of the starting point of the line as a pair of latitude and longitude.
* @param bCoordinates The coordinates of the ending point of the line as a pair of latitude and longitude.
* @param id An optional unique identifier for the line feature. If null, a unique id will be generated.
* @return A reference to the created `LineFeature`.
*/
public fun FeatureBuilder<Gmc>.line(
aCoordinates: Pair<Double, Double>,
bCoordinates: Pair<Double, Double>,
id: String? = null,
): FeatureRef<Gmc, LineFeature<Gmc>> = feature(
id,
LineFeature(space, coordinatesOf(aCoordinates), coordinatesOf(bCoordinates))
)
/**
* Adds an arc-shaped feature to the feature builder.
*
* @param center the geographical center of the arc represented as a pair of latitude and longitude in degrees
* @param radius the radius of the arc in kilometers
* @param startAngle the starting angle of the arc in radians, measured from the 3 o'clock position downwards
* @param arcLength the angular extent of the arc in radians
* @param id an optional identifier for the arc feature; if null, a unique identifier is generated
* @return a reference to the added arc feature
*/
public fun FeatureBuilder<Gmc>.arc(
center: Pair<Double, Double>,
radius: Distance,
startAngle: Angle,
arcLength: Angle,
id: String? = null,
): FeatureRef<Gmc, ArcFeature<Gmc>> = feature(
id,
ArcFeature(
space,
oval = space.Rectangle(coordinatesOf(center), radius, radius),
startAngle = startAngle,
arcLength = arcLength,
)
)
/**
* Adds a points feature to the current feature builder.
*
* @param points a list of pairs where each pair represents the latitude and longitude of a point.
* @param id an optional unique identifier for the feature. If null, a unique id is generated automatically.
* @return a reference to the created points feature in the feature store.
*/
public fun FeatureBuilder<Gmc>.points(
points: List<Pair<Double, Double>>,
id: String? = null,
): FeatureRef<Gmc, PointsFeature<Gmc>> = feature(id, PointsFeature(space, points.map(::coordinatesOf)))
/**
* Creates a MultiLineFeature composed of multiple line segments defined by the given points
* and adds it to the feature store. If an ID is provided, the feature is associated with that ID;
* otherwise, a unique ID is generated.
*
* @param points A list of pairs representing the points (latitude, longitude) that define the line segments.
* @param id An optional string identifier for the feature. If null, a unique ID is automatically created.
* @return A reference to the created MultiLineFeature.
*/
public fun FeatureBuilder<Gmc>.multiLine(
points: List<Pair<Double, Double>>,
id: String? = null,
): FeatureRef<Gmc, MultiLineFeature<Gmc>> = feature(id, MultiLineFeature(space, points.map(::coordinatesOf)))
/**
* Adds a vector icon feature to the map with a specified position, size, and image.
*
* @param position The geographic coordinates (latitude, longitude) of the icon's center.
* @param image The image to be displayed as the vector icon.
* @param size The size of the icon, specified as a [DpSize]. Defaults to 20.dp x 20.dp.
* @param id An optional unique identifier for the icon. If null, a unique ID is generated automatically.
* @return A reference to the created vector icon feature as a [FeatureRef].
*/
public fun FeatureBuilder<Gmc>.icon(
position: Pair<Double, Double>,
image: ImageVector,
size: DpSize = DpSize(20.dp, 20.dp),
id: String? = null,
): FeatureRef<Gmc, VectorIconFeature<Gmc>> = feature(
id,
VectorIconFeature(
space,
coordinatesOf(position),
size,
image,
)
)
/**
* Adds a text feature to the FeatureBuilder with the specified position, content, and font configuration.
*
* @param position The geographic position of the text as a pair of latitude and longitude.
* @param text The text content to be displayed.
* @param font A lambda to configure text font properties. Default size is 16f.
* @param id An optional identifier for the feature. If null, a unique ID is generated.
* @return A reference to the added TextFeature within the FeatureBuilder.
*/
public fun FeatureBuilder<Gmc>.text(
position: Pair<Double, Double>,
text: String,
font: Font.() -> Unit = { size = 16f },
id: String? = null,
): FeatureRef<Gmc, TextFeature<Gmc>> = feature(
id,
TextFeature(space, coordinatesOf(position), text, fontConfig = font)
)
/**
* Creates a pixel map feature within the specified geographic rectangle, using the provided latitude and longitude deltas to
* define pixel granularity and a builder function to determine pixel colors.
*
* @param rectangle The geographic rectangle defining the bounds of the pixel map.
* @param latitudeDelta The latitude increment between two rows of pixels.
* @param longitudeDelta The longitude increment between two columns of pixels.
* @param id The optional identifier for the feature. If null, a unique ID will be generated.
* @param builder A function that takes [Gmc] coordinates and returns the color for the corresponding pixel, or null for transparency.
* @return A reference to the pixel map feature, encapsulating its boundaries and content.
*/
public fun FeatureBuilder<Gmc>.pixelMap(
rectangle: Rectangle<Gmc>,
latitudeDelta: Angle,
longitudeDelta: Angle,
id: String? = null,
builder: (Gmc) -> Color?,
): FeatureRef<Gmc, PixelMapFeature<Gmc>> = feature(
id,
PixelMapFeature(
space,
rectangle,
Structure2D(
ceil(rectangle.longitudeDelta / latitudeDelta).toInt(),
ceil(rectangle.latitudeDelta / longitudeDelta).toInt()
) { (i, j) ->
val longitude = rectangle.left + longitudeDelta * i
val latitude = rectangle.bottom + latitudeDelta * j
builder(
Gmc(latitude, longitude)
)
}
)
)

View File

@@ -0,0 +1,91 @@
package center.sciprog.maps.compose
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.readBytes
import io.ktor.http.Url
import io.ktor.util.decodeBase64Bytes
import io.ktor.util.encodeBase64
import kotlinx.browser.window
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.jetbrains.skia.Image
import org.w3c.dom.Storage
/**
* A [MapTileProvider] based on Open Street Map API. With in-memory and file cache
*/
public class OpenStreetMapTileProvider(
private val client: HttpClient,
private val storage: Storage = window.localStorage,
parallelism: Int = 4,
cacheCapacity: Int = 200,
private val osmBaseUrl: String = "https://tile.openstreetmap.org",
) : MapTileProvider {
private val semaphore = Semaphore(parallelism)
private val cache = LruCache<TileId, Deferred<Image>>(cacheCapacity)
private fun TileId.osmUrl() = Url("$osmBaseUrl/${zoom}/${i}/${j}.png")
private fun TileId.imageName() = "${zoom}/${i}/${j}.png"
private fun TileId.readImage() = storage.getItem(imageName())
/**
* Download and cache the tile image
*/
private fun CoroutineScope.downloadImageAsync(id: TileId): Deferred<Image> = async {
id.readImage()?.let { imageString ->
try {
return@async Image.makeFromEncoded(imageString.decodeBase64Bytes())
} catch (ex: Exception) {
logger.debug { "Failed to load image from $imageString" }
storage.removeItem(id.imageName())
}
}
//semaphore works only for actual download
semaphore.withPermit {
val url = id.osmUrl()
val byteArray = client.get(url).readBytes()
logger.debug { "Finished downloading map tile with id $id from $url" }
val imageName = id.imageName()
logger.debug { "Caching map tile $id to $imageName" }
storage.setItem(imageName, byteArray.encodeBase64())
Image.makeFromEncoded(byteArray)
}
}
override fun CoroutineScope.loadTileAsync(
tileId: TileId,
): Deferred<MapTile> {
//start image download
val imageDeferred: Deferred<Image> = cache.getOrPut(tileId) {
downloadImageAsync(tileId)
}
//collect the result asynchronously
return async {
val image: Image = runCatching { imageDeferred.await() }.onFailure {
if (it !is CancellationException) {
logger.error(it) { "Failed to load tile image with id=$tileId" }
}
cache.remove(tileId)
}.getOrThrow()
MapTile(tileId, image)
}
}
public companion object {
private val logger = KotlinLogging.logger("OpenStreetMapCache")
}
}

View File

@@ -1,146 +0,0 @@
package center.sciprog.maps.compose
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import center.sciprog.attributes.z
import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.features.FeatureGroup
import center.sciprog.maps.features.PainterFeature
import center.sciprog.maps.features.drawFeature
import center.sciprog.maps.features.zoomRange
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import mu.KotlinLogging
import org.jetbrains.skia.Image
import org.jetbrains.skia.Paint
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
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("MapView")
/**
* A component that renders map and provides basic map manipulation capabilities
*/
@Composable
public actual fun MapView(
viewScope: MapViewScope,
features: FeatureGroup<Gmc>,
modifier: Modifier,
): Unit = with(viewScope) {
val mapTiles = remember(mapTileProvider) { mutableStateMapOf<TileId, Image>() }
// Load tiles asynchronously
LaunchedEffect(viewPoint, canvasSize) {
with(mapTileProvider) {
val indexRange = 0 until 2.0.pow(intZoom).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)
for (j in verticalIndices) {
for (i in horizontalIndices) {
val id = TileId(intZoom, i, j)
//ensure that failed tiles do not fail the application
supervisorScope {
//start all
val deferred = loadTileAsync(id)
//wait asynchronously for it to finish
launch {
try {
val tile = deferred.await()
mapTiles[tile.id] = tile.image
} catch (ex: Exception) {
//displaying the error is maps responsibility
logger.error(ex) { "Failed to load tile with id=$id" }
}
}
}
mapTiles.keys.filter {
it.zoom != intZoom || it.j !in verticalIndices || it.i !in horizontalIndices
}.forEach {
mapTiles.remove(it)
}
}
}
}
}
key(viewScope, features) {
val painterCache: Map<PainterFeature<Gmc>, Painter> =
features.features.filterIsInstance<PainterFeature<Gmc>>().associateWith { it.getPainter() }
Canvas(modifier = modifier.mapControls(viewScope, features)) {
if (canvasSize != size.toDpSize()) {
logger.debug { "Recalculate canvas. Size: $size" }
config.onCanvasSizeChange(canvasSize)
canvasSize = size.toDpSize()
}
clipRect {
val tileSize = IntSize(
ceil((mapTileProvider.tileSize.dp * tileScale).toPx()).toInt(),
ceil((mapTileProvider.tileSize.dp * tileScale).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).roundToPx(),
(canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale).roundToPx()
)
drawImage(
image = image.toComposeImageBitmap(),
dstOffset = offset,
dstSize = tileSize
)
}
features.featureMap.values.sortedBy { it.z }
.filter { viewPoint.zoom in it.zoomRange }
.forEach { feature ->
drawFeature(viewScope, painterCache, feature)
}
}
selectRect?.let { dpRect ->
val rect = dpRect.toRect()
drawRect(
color = Color.Blue,
topLeft = rect.topLeft,
size = rect.size,
alpha = 0.5f,
style = Stroke(
width = 2f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
)
)
}
}
}
}

View File

@@ -1,15 +1,14 @@
package center.sciprog.maps.compose
package space.kscience.maps.compose
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.readBytes
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import io.ktor.client.statement.readRawBytes
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import mu.KotlinLogging
import org.jetbrains.skia.Image
import java.net.URL
import java.nio.file.Path
@@ -25,12 +24,14 @@ public class OpenStreetMapTileProvider(
cacheCapacity: Int = 200,
private val osmBaseUrl: String = "https://tile.openstreetmap.org",
) : MapTileProvider {
private val cacheMutex = Mutex()
private val semaphore = Semaphore(parallelism)
private val cache = LruCache<TileId, Deferred<Image>>(cacheCapacity)
private fun TileId.osmUrl() = URL("$osmBaseUrl/${zoom}/${i}/${j}.png")
private fun TileId.cacheFilePath() = cacheDirectory.resolve("${zoom}/${i}/${j}.png")
private fun TileId.cacheFilePath() = cacheDirectory.resolve("${zoom}/${i}/${j}.png").takeIf { it.exists() }
/**
* Download and cache the tile image
@@ -51,7 +52,7 @@ public class OpenStreetMapTileProvider(
//semaphore works only for actual download
semaphore.withPermit {
val url = id.osmUrl()
val byteArray = client.get(url).readBytes()
val byteArray = client.get(url).readRawBytes()
logger.debug { "Finished downloading map tile with id $id from $url" }
id.cacheFilePath()?.let { path ->
logger.debug { "Caching map tile $id to $path" }
@@ -68,16 +69,22 @@ public class OpenStreetMapTileProvider(
tileId: TileId,
): Deferred<MapTile> {
//start image download
val imageDeferred: Deferred<Image> = cache.getOrPut(tileId) {
downloadImageAsync(tileId)
}
//collect the result asynchronously
return async {
//start image download
val imageDeferred: Deferred<Image> = cacheMutex.withLock {
cache.getOrPut(tileId) {
downloadImageAsync(tileId)
}
}
val image: Image = runCatching { imageDeferred.await() }.onFailure {
logger.error(it) { "Failed to load tile image with id=$tileId" }
cache.remove(tileId)
if (it !is CancellationException) {
logger.error(it) { "Failed to load tile image with id=$tileId" }
}
cacheMutex.withLock {
cache.remove(tileId)
}
}.getOrThrow()
MapTile(tileId, image)

View File

@@ -0,0 +1,62 @@
package space.kscience.maps.compose
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.asSkiaBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import org.jetbrains.skia.Image
import org.jfree.svg.SVGUtils
import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.features.FeatureSet
import space.kscience.maps.features.PainterFeature
import space.kscience.maps.features.ViewConfig
import space.kscience.maps.features.ViewPoint
import space.kscience.maps.svg.generateBitmap
import space.kscience.maps.svg.generateSvg
import java.nio.file.Path
import kotlin.io.path.writeBytes
/**
* Exports the features of a [FeatureSet] to an SVG file.
*
* @param viewPoint The `ViewPoint` providing the spatial context (focus and zoom level) for rendering the SVG.
* @param painterCache A map associating `PainterFeature` instances with their respective `Painter` objects,
* used for rendering the features in the SVG.
* @param size The dimensions (`Size`) of the SVG file to generate.
* @param path The file path where the generated SVG will be saved.
*/
public fun FeatureSet<Gmc>.exportToSvg(
viewPoint: ViewPoint<Gmc>,
painterCache: Map<PainterFeature<Gmc>, Painter>,
size: Size,
path: Path,
) {
val mapCanvasState: MapCanvasState = MapCanvasState(ViewConfig()).apply {
this.viewPoint = viewPoint
this.canvasSize = DpSize(size.width.dp, size.height.dp)
}
val svgString: String = generateSvg(mapCanvasState, painterCache)
SVGUtils.writeToSVG(path.toFile(), svgString)
}
public fun FeatureSet<Gmc>.exportToPng(
viewPoint: ViewPoint<Gmc>,
painterCache: Map<PainterFeature<Gmc>, Painter>,
textMeasurer: TextMeasurer,
size: Size,
path: Path,
) {
val mapCanvasState: MapCanvasState = MapCanvasState(ViewConfig()).apply {
this.viewPoint = viewPoint
this.canvasSize = DpSize(size.width.dp, size.height.dp)
}
val bitmap = generateBitmap(mapCanvasState, painterCache, textMeasurer, size)
Image.makeFromBitmap(bitmap.asSkiaBitmap()).encodeToData()?.bytes?.let {
path.writeBytes(it)
}
}

View File

@@ -1,15 +1,13 @@
package center.sciprog.maps.compose
package space.kscience.maps.compose
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.nio.file.Files
import kotlin.test.assertFails
@OptIn(ExperimentalCoroutinesApi::class)
class OsmTileProviderTest {
// @get:Rule
// val rule = createComposeRule()

View File

@@ -9,19 +9,8 @@ The core interfaces of KMath.
## Artifact:
The Maven coordinates of this project are `center.sciprog:maps-kt-core:0.2.2`.
The Maven coordinates of this project are `space.kscience:maps-kt-core:0.4.0-dev-7`.
**Gradle Groovy:**
```groovy
repositories {
maven { url 'https://repo.kotlin.link' }
mavenCentral()
}
dependencies {
implementation 'center.sciprog:maps-kt-core:0.2.2'
}
```
**Gradle Kotlin DSL:**
```kotlin
repositories {
@@ -30,6 +19,6 @@ repositories {
}
dependencies {
implementation("center.sciprog:maps-kt-core:0.2.2")
implementation("space.kscience:maps-kt-core:0.4.0-dev-7")
}
```

View File

@@ -3,11 +3,12 @@ plugins {
`maven-publish`
}
val kmathVersion: String by rootProject.extra
kscience{
jvm()
js()
native()
wasm()
useSerialization()
dependencies{
@@ -18,7 +19,6 @@ kscience{
readme {
description = "Core cartography, UI-agnostic"
maturity = space.kscience.gradle.Maturity.DEVELOPMENT
propertyByTemplate("artifact", rootProject.file("docs/templates/ARTIFACT-TEMPLATE.md"))
feature(
id = "angles and distances",

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.coordinates
package space.kscience.maps.coordinates
import kotlinx.serialization.Serializable
import kotlin.jvm.JvmInline

View File

@@ -1,10 +1,8 @@
package center.sciprog.maps.coordinates
package space.kscience.maps.coordinates
import kotlinx.serialization.Serializable
import space.kscience.kmath.geometry.Angle
import space.kscience.kmath.geometry.tan
import kotlin.math.pow
import kotlin.math.sqrt
import space.kscience.kmath.geometry.*
import kotlin.math.*
@Serializable
public class GeoEllipsoid(public val equatorRadius: Distance, public val polarRadius: Distance) {
@@ -43,12 +41,7 @@ public class GeoEllipsoid(public val equatorRadius: Distance, public val polarRa
polarRadius = Distance(6378.137)
)
// /**
// * https://en.wikipedia.org/wiki/Great-circle_distance
// */
// public fun greatCircleAngleBetween(r1: GMC, r2: GMC): Radians = acos(
// sin(r1.latitude) * sin(r2.latitude) + cos(r1.latitude) * cos(r2.latitude) * cos(r1.longitude - r2.longitude)
// ).radians
}
}
@@ -59,22 +52,35 @@ public fun GeoEllipsoid.reducedRadius(latitude: Angle): Distance {
val reducedLatitudeTan = (1 - f) * tan(latitude)
return equatorRadius / sqrt(1.0 + reducedLatitudeTan.pow(2))
}
//
//
///**
// * Compute distance between two map points using giv
// * https://en.wikipedia.org/wiki/Geographical_distance#Lambert's_formula_for_long_lines
// */
//public fun GeoEllipsoid.lambertDistanceBetween(r1: GMC, r2: GMC): Distance {
// val s = greatCircleAngleBetween(r1, r2)
//
// val b1: Double = (1 - f) * tan(r1.latitude)
// val b2: Double = (1 - f) * tan(r2.latitude)
// val p = (b1 + b2) / 2
// val q = (b2 - b1) / 2
//
// val x = (s.value - sin(s)) * sin(p).pow(2) * cos(q).pow(2) / cos(s / 2).pow(2)
// val y = (s.value + sin(s)) * cos(p).pow(2) * sin(q).pow(2) / sin(s / 2).pow(2)
//
// return equatorRadius * (s.value - f / 2 * (x + y))
//}
/**
* Compute distance between two map points using giv
* https://en.wikipedia.org/wiki/Geographical_distance#Lambert's_formula_for_long_lines
*/
public fun GeoEllipsoid.lambertDistanceBetween(r1: Gmc, r2: Gmc): Distance {
/**
* https://en.wikipedia.org/wiki/Great-circle_distance
*/
fun greatCircleAngleBetween(
r1: Gmc,
r2: Gmc,
): Radians = acos(
sin(r1.latitude) * sin(r2.latitude) +
cos(r1.latitude) * cos(r2.latitude) *
cos(r1.longitude - r2.longitude)
).radians
val s = greatCircleAngleBetween(r1, r2)
val b1: Double = (1 - f) * tan(r1.latitude)
val b2: Double = (1 - f) * tan(r2.latitude)
val p = (b1 + b2) / 2
val q = (b2 - b1) / 2
val x = (s.value - sin(s)) * sin(p).pow(2) * cos(q).pow(2) / cos(s / 2).pow(2)
val y = (s.value + sin(s)) * cos(p).pow(2) * sin(q).pow(2) / sin(s / 2).pow(2)
return equatorRadius * (s.value - f / 2 * (x + y))
}

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.coordinates
package space.kscience.maps.coordinates
import kotlinx.serialization.Serializable
import space.kscience.kmath.geometry.*
@@ -22,7 +22,6 @@ public class GeodeticMapCoordinates(
"Longitude $longitude is not in (-PI..PI) range"
}
}
override val x: Angle get() = longitude
override val y: Angle get() = latitude
@@ -43,7 +42,7 @@ public class GeodeticMapCoordinates(
}
override fun toString(): String {
return "GMC(latitude=${latitude.degrees} deg, longitude=${longitude.degrees} deg)"
return "GMC(latitude=${latitude.toDegrees().value} deg, longitude=${longitude.toDegrees().value} deg)"
}

View File

@@ -1,14 +1,14 @@
package center.sciprog.maps.coordinates
package space.kscience.maps.coordinates
import space.kscience.kmath.geometry.*
import kotlin.math.*
/**
* A directed straight (geodetic) segment on a spheroid with given start, direction, end point and distance.
* @param forward coordinate of a start point with forward direction
* @param backward coordinate of an end point with backward direction
* A directed straight (geodetic) segment on a spheroid with the given start, direction, end point and distance.
* @param forward coordinate of a start point with the forward direction
* @param backward coordinate of an end point with the backward direction
*/
public class GmcCurve(
public class GmcCurve internal constructor(
public val forward: GmcPose,
public val backward: GmcPose,
public val distance: Distance,
@@ -64,8 +64,8 @@ public fun GeoEllipsoid.meridianCurve(
}
return GmcCurve(
forward = GmcPose(Gmc.normalized(fromLatitude, longitude), if (up) Angle.zero else Angle.pi),
backward = GmcPose(Gmc.normalized(toLatitude, longitude), if (up) Angle.pi else Angle.zero),
forward = GmcPose(GeodeticMapCoordinates.normalized(fromLatitude, longitude), if (up) Angle.zero else Angle.pi),
backward = GmcPose(GeodeticMapCoordinates.normalized(toLatitude, longitude), if (up) Angle.pi else Angle.zero),
distance = s
)
}
@@ -77,9 +77,9 @@ public fun GeoEllipsoid.parallelCurve(latitude: Angle, fromLongitude: Angle, toL
require(latitude in (-Angle.piDiv2)..(Angle.piDiv2)) { "Latitude must be in (-90, 90) degrees range" }
val right = toLongitude > fromLongitude
return GmcCurve(
forward = GmcPose(Gmc.normalized(latitude, fromLongitude), if (right) Angle.piDiv2 else -Angle.piDiv2),
backward = GmcPose(Gmc.normalized(latitude, toLongitude), if (right) -Angle.piDiv2 else Angle.piDiv2),
distance = reducedRadius(latitude) * abs((fromLongitude - toLongitude).radians)
forward = GmcPose(GeodeticMapCoordinates.normalized(latitude, fromLongitude), if (right) Angle.piDiv2 else -Angle.piDiv2),
backward = GmcPose(GeodeticMapCoordinates.normalized(latitude, toLongitude), if (right) -Angle.piDiv2 else Angle.piDiv2),
distance = reducedRadius(latitude) * abs((fromLongitude - toLongitude).toRadians().value)
)
}
@@ -193,7 +193,7 @@ public fun GeoEllipsoid.curveInDirection(
val L = lambda - (1 - C) * f * sinAlpha *
(sigma.value + C * sinSigma * (cosSigmaM2 + C * cosSigma * (-1 + 2 * cos2SigmaM2)))
val endPoint = Gmc.normalized(phi2, start.longitude + L.radians)
val endPoint = GeodeticMapCoordinates.normalized(phi2, start.longitude + L.radians)
// eq. 12

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.coordinates
package space.kscience.maps.coordinates
import kotlinx.serialization.Serializable
import space.kscience.kmath.geometry.Angle

View File

@@ -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 center.sciprog.maps.coordinates
package space.kscience.maps.coordinates
import kotlinx.serialization.Serializable
import space.kscience.kmath.geometry.*
@@ -57,12 +57,12 @@ public open class MercatorProjection(
return if (ellipsoid === GeoEllipsoid.sphere) {
GeodeticMapCoordinates.ofRadians(
atan(sinh(pc.y / ellipsoid.equatorRadius)),
baseLongitude.radians + (pc.x / ellipsoid.equatorRadius),
baseLongitude.toRadians().value + (pc.x / ellipsoid.equatorRadius),
)
} else {
GeodeticMapCoordinates.ofRadians(
cphi2(exp(-(pc.y / ellipsoid.equatorRadius))),
baseLongitude.radians + (pc.x / ellipsoid.equatorRadius)
baseLongitude.toRadians().value + (pc.x / ellipsoid.equatorRadius)
)
}
}
@@ -76,13 +76,13 @@ public open class MercatorProjection(
return if (ellipsoid === GeoEllipsoid.sphere) {
ProjectionCoordinates(
x = ellipsoid.equatorRadius * (gmc.longitude - baseLongitude).radians,
x = ellipsoid.equatorRadius * (gmc.longitude - baseLongitude).toRadians().value,
y = ellipsoid.equatorRadius * ln(tan(Angle.pi / 4 + gmc.latitude / 2))
)
} else {
val sinPhi = sin(gmc.latitude)
ProjectionCoordinates(
x = ellipsoid.equatorRadius * (gmc.longitude - baseLongitude).radians,
x = ellipsoid.equatorRadius * (gmc.longitude - baseLongitude).toRadians().value,
y = ellipsoid.equatorRadius * ln(
tan(Angle.pi / 4 + gmc.latitude / 2) * ((1 - e * sinPhi) / (1 + e * sinPhi)).pow(e / 2)
)

View File

@@ -3,10 +3,9 @@
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
package center.sciprog.maps.coordinates
package space.kscience.maps.coordinates
import space.kscience.kmath.geometry.abs
import space.kscience.kmath.geometry.radians
import kotlin.math.*
public data class WebMercatorCoordinates(val zoom: Int, val x: Float, val y: Float)
@@ -36,8 +35,8 @@ public object WebMercatorProjection {
val scaleFactor = scaleFactor(zoom.toFloat())
return WebMercatorCoordinates(
zoom = zoom,
x = scaleFactor * (gmc.longitude.radians + PI).toFloat(),
y = scaleFactor * (PI - ln(tan(PI / 4 + gmc.latitude.radians / 2))).toFloat()
x = scaleFactor * (gmc.longitude.toRadians().value + PI).toFloat(),
y = scaleFactor * (PI - ln(tan(PI / 4 + gmc.latitude.toRadians().value / 2))).toFloat()
)
}

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.coordinates
package space.kscience.maps.coordinates
import space.kscience.kmath.geometry.radians
import kotlin.test.Test
@@ -6,8 +6,8 @@ import kotlin.test.assertEquals
internal class DistanceTest {
companion object {
val moscow = Gmc.ofDegrees(55.76058287719673, 37.60358622841869)
val spb = Gmc.ofDegrees(59.926686023580444, 30.36038109122013)
val moscow = GeodeticMapCoordinates.ofDegrees(55.76058287719673, 37.60358622841869)
val spb = GeodeticMapCoordinates.ofDegrees(59.926686023580444, 30.36038109122013)
}
@Test
@@ -21,7 +21,7 @@ internal class DistanceTest {
val distance = curve.distance
assertEquals(632.035426877, distance.kilometers, 0.0001)
assertEquals(-0.6947937116552751, curve.forward.bearing.radians, 0.0001)
assertEquals(-0.6947937116552751, curve.forward.bearing.toRadians().value, 0.0001)
}
@Test
@@ -30,7 +30,7 @@ internal class DistanceTest {
GmcPose(moscow, (-0.6947937116552751).radians), Distance(632.035426877)
)
assertEquals(spb.latitude.radians, curve.backward.latitude.radians, 0.0001)
assertEquals(spb.longitude.radians, curve.backward.longitude.radians, 0.0001)
assertEquals(spb.latitude.toRadians().value, curve.backward.latitude.toRadians().value, 0.0001)
assertEquals(spb.longitude.toRadians().value, curve.backward.longitude.toRadians().value, 0.0001)
}
}

View File

@@ -1,29 +1,28 @@
package center.sciprog.maps.coordinates
package space.kscience.maps.coordinates
import space.kscience.kmath.geometry.degrees
import kotlin.test.Test
import kotlin.test.assertEquals
class MercatorTest {
@Test
fun sphereForwardBackward(){
val moscow = Gmc.ofDegrees(55.76058287719673, 37.60358622841869)
val moscow = GeodeticMapCoordinates.ofDegrees(55.76058287719673, 37.60358622841869)
val mercator = MapProjection.epsg3857.toProjection(moscow)
//https://epsg.io/transform#s_srs=4326&t_srs=3857&x=37.6035862&y=55.7605829
assertEquals(4186.0120709, mercator.x.kilometers, 1e-4)
assertEquals(7510.9013658, mercator.y.kilometers, 1e-4)
val backwards = MapProjection.epsg3857.toGeodetic(mercator)
assertEquals(moscow.latitude.degrees, backwards.latitude.degrees, 1e-6)
assertEquals(moscow.longitude.degrees, backwards.longitude.degrees, 1e-6)
assertEquals(moscow.latitude.toDegrees().value, backwards.latitude.toDegrees().value, 1e-6)
assertEquals(moscow.longitude.toDegrees().value, backwards.longitude.toDegrees().value, 1e-6)
}
@Test
fun ellipseForwardBackward(){
val moscow = Gmc.ofDegrees(55.76058287719673, 37.60358622841869)
val moscow = GeodeticMapCoordinates.ofDegrees(55.76058287719673, 37.60358622841869)
val projection = MercatorProjection(ellipsoid = GeoEllipsoid.WGS84)
val mercator = projection.toProjection(moscow)
val backwards = projection.toGeodetic(mercator)
assertEquals(moscow.latitude.degrees, backwards.latitude.degrees, 1e-6)
assertEquals(moscow.longitude.degrees, backwards.longitude.degrees, 1e-6)
assertEquals(moscow.latitude.toDegrees().value, backwards.latitude.toDegrees().value, 1e-6)
assertEquals(moscow.longitude.toDegrees().value, backwards.longitude.toDegrees().value, 1e-6)
}
}

View File

@@ -6,19 +6,8 @@
## Artifact:
The Maven coordinates of this project are `center.sciprog:maps-kt-features:0.2.2`.
The Maven coordinates of this project are `space.kscience:maps-kt-features:0.4.0-dev-7`.
**Gradle Groovy:**
```groovy
repositories {
maven { url 'https://repo.kotlin.link' }
mavenCentral()
}
dependencies {
implementation 'center.sciprog:maps-kt-features:0.2.2'
}
```
**Gradle Kotlin DSL:**
```kotlin
repositories {
@@ -27,6 +16,6 @@ repositories {
}
dependencies {
implementation("center.sciprog:maps-kt-features:0.2.2")
implementation("space.kscience:maps-kt-features:0.4.0-dev-7")
}
```

View File

@@ -1,30 +1,45 @@
plugins {
id("space.kscience.gradle.mpp")
id("org.jetbrains.compose")
alias(spclibs.plugins.compose.compiler)
alias(spclibs.plugins.compose.jb)
`maven-publish`
}
val kmathVersion: String by rootProject.extra
kscience{
kscience {
jvm()
js()
useSerialization{
json()
}
useSerialization(sourceSet = space.kscience.gradle.DependencySourceSet.TEST){
protobuf()
}
}
kotlin {
sourceSets {
commonMain {
dependencies {
api(projects.trajectoryKt)
api(compose.foundation)
// js()
wasm{
browser {
testTask {
enabled = false
}
}
}
useCoroutines()
useSerialization {
json()
}
useSerialization(sourceSet = space.kscience.gradle.DependencySourceSet.TEST) {
protobuf()
}
commonMain{
api(projects.trajectoryKt)
api(compose.runtime)
api(compose.foundation)
api(compose.material)
api(compose.ui)
api(libs.attributes.serialization)
api("io.github.oshai:kotlin-logging:7.0.7")
}
jvmMain{
api("org.jfree:org.jfree.svg:5.0.6")
}
}

View File

@@ -1,22 +0,0 @@
package center.sciprog.attributes
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.serializer
public interface Attribute<T>
public abstract class SerializableAttribute<T>(
public val serialId: String,
public val serializer: KSerializer<T>,
) : Attribute<T> {
override fun toString(): String = serialId
}
public interface AttributeWithDefault<T> : Attribute<T> {
public val default: T
}
public interface SetAttribute<V> : Attribute<Set<V>>
public object NameAttribute : SerializableAttribute<String>("name", String.serializer())

View File

@@ -1,74 +0,0 @@
package center.sciprog.attributes
import center.sciprog.maps.features.Feature
import center.sciprog.maps.features.ZAttribute
import kotlin.jvm.JvmInline
@JvmInline
public value class Attributes internal constructor(public val content: Map<out Attribute<*>, Any>) {
public val keys: Set<Attribute<*>> get() = content.keys
@Suppress("UNCHECKED_CAST")
public operator fun <T> get(attribute: Attribute<T>): T? = content[attribute] as? T
override fun toString(): String = "Attributes(value=${content.entries})"
public companion object {
public val EMPTY: Attributes = Attributes(emptyMap())
}
}
public fun Attributes.isEmpty(): Boolean = content.isEmpty()
public fun <T> Attributes.getOrDefault(attribute: AttributeWithDefault<T>): T = get(attribute) ?: attribute.default
public fun <T, A : Attribute<T>> Attributes.withAttribute(
attribute: A,
attrValue: T?,
): Attributes = Attributes(
if (attrValue == null) {
content - attribute
} else {
content + (attribute to attrValue)
}
)
/**
* Add an element to a [SetAttribute]
*/
public fun <T, A : SetAttribute<T>> Attributes.withAttributeElement(
attribute: A,
attrValue: T,
): Attributes {
val currentSet: Set<T> = get(attribute) ?: emptySet()
return Attributes(
content + (attribute to (currentSet + attrValue))
)
}
/**
* Remove an element from [SetAttribute]
*/
public fun <T, A : SetAttribute<T>> Attributes.withoutAttributeElement(
attribute: A,
attrValue: T,
): Attributes {
val currentSet: Set<T> = get(attribute) ?: emptySet()
return Attributes(
content + (attribute to (currentSet - attrValue))
)
}
public fun <T : Any, A : Attribute<T>> Attributes(
attribute: A,
attrValue: T,
): Attributes = Attributes(mapOf(attribute to attrValue))
public operator fun Attributes.plus(other: Attributes): Attributes = Attributes(content + other.content)
public val Feature<*>.z: Float
get() = attributes[ZAttribute] ?: 0f
// set(value) {
// attributes[ZAttribute] = value
// }

View File

@@ -1,47 +0,0 @@
package center.sciprog.attributes
/**
* A safe builder for [Attributes]
*/
public class AttributesBuilder internal constructor(private val map: MutableMap<Attribute<*>, Any> = mutableMapOf()) {
@Suppress("UNCHECKED_CAST")
public operator fun <T> get(attribute: Attribute<T>): T? = map[attribute] as? T
public operator fun <V> Attribute<V>.invoke(value: V?) {
if (value == null) {
map.remove(this)
} else {
map[this] = value
}
}
public fun from(attributes: Attributes) {
map.putAll(attributes.content)
}
public fun <V> SetAttribute<V>.add(
attrValue: V,
) {
val currentSet: Set<V> = get(this) ?: emptySet()
map[this] = currentSet + attrValue
}
/**
* Remove an element from [SetAttribute]
*/
public fun <V> SetAttribute<V>.remove(
attrValue: V,
) {
val currentSet: Set<V> = get(this) ?: emptySet()
map[this] = currentSet - attrValue
}
public fun build(): Attributes = Attributes(map)
}
public fun AttributesBuilder(
attributes: Attributes,
): AttributesBuilder = AttributesBuilder(attributes.content.toMutableMap())
public fun Attributes(builder: AttributesBuilder.() -> Unit): Attributes = AttributesBuilder().apply(builder).build()

View File

@@ -1,55 +0,0 @@
@file:Suppress("UNCHECKED_CAST")
package center.sciprog.attributes
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
public class AttributesSerializer(
private val serializableAttributes: Set<SerializableAttribute<*>>,
) : KSerializer<Attributes> {
private val jsonSerializer = JsonObject.serializer()
override val descriptor: SerialDescriptor get() = jsonSerializer.descriptor
override fun deserialize(decoder: Decoder): Attributes {
val jsonElement = jsonSerializer.deserialize(decoder)
val attributeMap: Map<SerializableAttribute<*>, Any> = jsonElement.entries.associate { (key, element) ->
val attr = serializableAttributes.find { it.serialId == key }
?: error("Attribute serializer for key $key not found")
val json = if (decoder is JsonDecoder) {
decoder.json
} else {
Json { serializersModule = decoder.serializersModule }
}
val value = json.decodeFromJsonElement(attr.serializer, element) ?: error("Null values are not allowed")
attr to value
}
return Attributes(attributeMap)
}
override fun serialize(encoder: Encoder, value: Attributes) {
val json = buildJsonObject {
value.content.forEach { (key: Attribute<*>, value: Any) ->
if (key !in serializableAttributes) error("An attribute key '$key' is not in the list of allowed attributes for this serializer")
val serializableKey = key as SerializableAttribute
val json = if (encoder is JsonEncoder) {
encoder.json
} else {
Json { serializersModule = encoder.serializersModule }
}
put(
serializableKey.serialId,
json.encodeToJsonElement(serializableKey.serializer as KSerializer<Any>, value)
)
}
}
jsonSerializer.serialize(encoder, json)
}
}

View File

@@ -1,5 +0,0 @@
package center.sciprog.maps.features
public expect class FeatureFont {
public var size: Float
}

View File

@@ -1,305 +0,0 @@
package center.sciprog.maps.features
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateMap
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.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.attributes.*
import space.kscience.kmath.geometry.Angle
import space.kscience.kmath.nd.*
import space.kscience.kmath.structures.Buffer
//@JvmInline
//public value class FeatureId<out F : Feature<*>>(public val id: String)
public class FeatureRef<T : Any, out F : Feature<T>>(public val id: String, public val parent: FeatureGroup<T>)
@Suppress("UNCHECKED_CAST")
public fun <T : Any, F : Feature<T>> FeatureRef<T, F>.resolve(): F =
parent.featureMap[id]?.let { it as F } ?: error("Feature with id=$id not found")
public val <T : Any, F : Feature<T>> FeatureRef<T, F>.attributes: Attributes get() = resolve().attributes
/**
* A group of other features
*/
public data class FeatureGroup<T : Any>(
override val space: CoordinateSpace<T>,
public val featureMap: SnapshotStateMap<String, Feature<T>> = mutableStateMapOf(),
override val attributes: Attributes = Attributes.EMPTY,
) : CoordinateSpace<T> by space, Feature<T> {
//
// @Suppress("UNCHECKED_CAST")
// public operator fun <F : Feature<T>> get(id: FeatureId<F>): F =
// featureMap[id.id]?.let { it as F } ?: error("Feature with id=$id not found")
private var uidCounter = 0
private fun generateUID(feature: Feature<T>?): String = if (feature == null) {
"@group[${uidCounter++}]"
} else {
"@${feature::class.simpleName}[${uidCounter++}]"
}
public fun <F : Feature<T>> feature(id: String?, feature: F): FeatureRef<T, F> {
val safeId = id ?: generateUID(feature)
featureMap[safeId] = feature
return FeatureRef(safeId, this)
}
public fun removeFeature(id: String) {
featureMap.remove(id)
}
// public fun <F : Feature<T>> feature(id: FeatureId<F>, feature: F): FeatureId<F> = feature(id.id, feature)
public val features: Collection<Feature<T>> get() = featureMap.values.sortedByDescending { it.z }
public fun visit(visitor: FeatureGroup<T>.(id: String, feature: Feature<T>) -> Unit) {
featureMap.entries.sortedByDescending { it.value.z }.forEach { (key, feature) ->
if (feature is FeatureGroup<T>) {
feature.visit(visitor)
} else {
visitor(this, key, feature)
}
}
}
public fun visitUntil(visitor: FeatureGroup<T>.(id: String, feature: Feature<T>) -> Boolean) {
featureMap.entries.sortedByDescending { it.value.z }.forEach { (key, feature) ->
if (feature is FeatureGroup<T>) {
feature.visitUntil(visitor)
} else {
if (!visitor(this, key, feature)) return@visitUntil
}
}
}
//
// @Suppress("UNCHECKED_CAST")
// public fun <A> getAttribute(id: FeatureId<Feature<T>>, key: Attribute<A>): A? =
// get(id).attributes[key]
override fun getBoundingBox(zoom: Float): Rectangle<T>? = with(space) {
featureMap.values.mapNotNull { it.getBoundingBox(zoom) }.wrapRectangles()
}
override fun withAttributes(modify: Attributes.() -> Attributes): Feature<T> = copy(attributes = modify(attributes))
public companion object {
/**
* Build, but do not remember map feature state
*/
public fun <T : Any> build(
coordinateSpace: CoordinateSpace<T>,
builder: FeatureGroup<T>.() -> Unit = {},
): FeatureGroup<T> = FeatureGroup(coordinateSpace).apply(builder)
/**
* Build and remember map feature state
*/
@Composable
public fun <T : Any> remember(
coordinateSpace: CoordinateSpace<T>,
builder: FeatureGroup<T>.() -> Unit = {},
): FeatureGroup<T> = remember {
build(coordinateSpace, builder)
}
}
}
/**
* Process all features with a given attribute from the one with highest [z] to lowest
*/
public fun <T : Any, A> FeatureGroup<T>.forEachWithAttribute(
key: Attribute<A>,
block: FeatureGroup<T>.(id: String, feature: Feature<T>, attributeValue: A) -> Unit,
) {
visit { id, feature ->
feature.attributes[key]?.let {
block(id, feature, it)
}
}
}
public fun <T : Any, A> FeatureGroup<T>.forEachWithAttributeUntil(
key: Attribute<A>,
block: FeatureGroup<T>.(id: String, feature: Feature<T>, attributeValue: A) -> Boolean,
) {
visitUntil { id, feature ->
feature.attributes[key]?.let {
block(id, feature, it)
} ?: true
}
}
public inline fun <T : Any, reified F : Feature<T>> FeatureGroup<T>.forEachWithType(
crossinline block: (FeatureRef<T, F>) -> Unit,
) {
visit { id, feature ->
if (feature is F) block(FeatureRef(id, this))
}
}
public inline fun <T : Any, reified F : Feature<T>> FeatureGroup<T>.forEachWithTypeUntil(
crossinline block: (FeatureRef<T, F>) -> Boolean,
) {
visitUntil { id, feature ->
if (feature is F) block(FeatureRef(id, this)) else true
}
}
public fun <T : Any> FeatureGroup<T>.circle(
center: T,
size: Dp = 5.dp,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, CircleFeature<T>> = feature(
id, CircleFeature(space, center, size, attributes)
)
public fun <T : Any> FeatureGroup<T>.rectangle(
centerCoordinates: T,
size: DpSize = DpSize(5.dp, 5.dp),
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, RectangleFeature<T>> = feature(
id, RectangleFeature(space, centerCoordinates, size, attributes)
)
public fun <T : Any> FeatureGroup<T>.draw(
position: T,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
draw: DrawScope.() -> Unit,
): FeatureRef<T, DrawFeature<T>> = feature(
id,
DrawFeature(space, position, drawFeature = draw, attributes = attributes)
)
public fun <T : Any> FeatureGroup<T>.line(
aCoordinates: T,
bCoordinates: T,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, LineFeature<T>> = feature(
id,
LineFeature(space, aCoordinates, bCoordinates, attributes)
)
public fun <T : Any> FeatureGroup<T>.arc(
oval: Rectangle<T>,
startAngle: Angle,
arcLength: Angle,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, ArcFeature<T>> = feature(
id,
ArcFeature(space, oval, startAngle, arcLength, attributes)
)
public fun <T : Any> FeatureGroup<T>.points(
points: List<T>,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, PointsFeature<T>> = feature(
id,
PointsFeature(space, points, attributes)
)
public fun <T : Any> FeatureGroup<T>.multiLine(
points: List<T>,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, MultiLineFeature<T>> = feature(
id,
MultiLineFeature(space, points, attributes)
)
public fun <T : Any> FeatureGroup<T>.polygon(
points: List<T>,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, PolygonFeature<T>> = feature(
id,
PolygonFeature(space, points, attributes)
)
public fun <T : Any> FeatureGroup<T>.icon(
position: T,
image: ImageVector,
size: DpSize = DpSize(image.defaultWidth, image.defaultHeight),
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, VectorIconFeature<T>> =
feature(
id,
VectorIconFeature(
space,
position,
size,
image,
attributes
)
)
public fun <T : Any> FeatureGroup<T>.group(
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
builder: FeatureGroup<T>.() -> Unit,
): FeatureRef<T, FeatureGroup<T>> {
val collection = FeatureGroup(space).apply(builder)
val feature = FeatureGroup(space, collection.featureMap, attributes)
return feature(id, feature)
}
public fun <T : Any> FeatureGroup<T>.scalableImage(
box: Rectangle<T>,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
painter: @Composable () -> Painter,
): FeatureRef<T, ScalableImageFeature<T>> = feature(
id,
ScalableImageFeature<T>(space, box, painter = painter, attributes = attributes)
)
public fun <T : Any> FeatureGroup<T>.text(
position: T,
text: String,
font: FeatureFont.() -> Unit = { size = 16f },
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, TextFeature<T>> = feature(
id,
TextFeature(space, position, text, fontConfig = font, attributes = attributes)
)
public fun <T> StructureND(shape: ShapeND, initializer: (IntArray) -> T): StructureND<T> {
val strides = Strides(shape)
return BufferND(strides, Buffer.boxing(strides.linearSize) { initializer(strides.index(it)) })
}
public fun <T> Structure2D(rows: Int, columns: Int, initializer: (IntArray) -> T): Structure2D<T> {
val strides = Strides(ShapeND(rows, columns))
return BufferND(strides, Buffer.boxing(strides.linearSize) { initializer(strides.index(it)) }).as2D()
}
public fun <T : Any> FeatureGroup<T>.pixelMap(
rectangle: Rectangle<T>,
pixelMap: Structure2D<Color?>,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, PixelMapFeature<T>> = feature(
id,
PixelMapFeature(space, rectangle, pixelMap, attributes = attributes)
)

View File

@@ -1,82 +0,0 @@
package center.sciprog.maps.features
import center.sciprog.attributes.Attributes
import kotlin.jvm.JvmName
public fun <T : Any> FeatureGroup<T>.draggableLine(
aId: FeatureRef<T, MarkerFeature<T>>,
bId: FeatureRef<T, MarkerFeature<T>>,
id: String? = null,
): FeatureRef<T, LineFeature<T>> {
var lineId: FeatureRef<T, LineFeature<T>>? = null
fun drawLine(): FeatureRef<T, LineFeature<T>> {
val currentId = feature(
lineId?.id ?: id,
LineFeature(
space,
aId.resolve().center,
bId.resolve().center,
Attributes {
ZAttribute(-10f)
lineId?.attributes?.let { from(it) }
}
)
)
lineId = currentId
return currentId
}
aId.draggable { _, _ ->
drawLine()
}
bId.draggable { _, _ ->
drawLine()
}
return drawLine()
}
public fun <T : Any> FeatureGroup<T>.draggableMultiLine(
points: List<FeatureRef<T, MarkerFeature<T>>>,
id: String? = null,
): FeatureRef<T, MultiLineFeature<T>> {
var polygonId: FeatureRef<T, MultiLineFeature<T>>? = null
fun drawLines(): FeatureRef<T, MultiLineFeature<T>> {
val currentId = feature(
polygonId?.id ?: id,
MultiLineFeature(
space,
points.map { it.resolve().center },
Attributes {
ZAttribute(-10f)
polygonId?.attributes?.let { from(it) }
}
)
)
polygonId = currentId
return currentId
}
points.forEach {
it.draggable { _, _ ->
drawLines()
}
}
return drawLines()
}
@JvmName("draggableMultiLineFromPoints")
public fun <T : Any> FeatureGroup<T>.draggableMultiLine(
points: List<T>,
id: String? = null,
): FeatureRef<T, MultiLineFeature<T>> {
val pointRefs = points.map {
circle(it)
}
return draggableMultiLine(pointRefs, id)
}

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.compose
package space.kscience.maps.compose
import androidx.compose.foundation.gestures.drag
import androidx.compose.ui.Modifier
@@ -7,7 +7,7 @@ import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.dp
import center.sciprog.maps.features.*
import space.kscience.maps.features.*
import kotlin.math.max
import kotlin.math.min
@@ -16,9 +16,9 @@ import kotlin.math.min
* Create a modifier for Map/Scheme canvas controls on desktop
* @param features a collection of features to be rendered in descending [ZAttribute] order
*/
public fun <T : Any> Modifier.mapControls(
state: CoordinateViewScope<T>,
features: FeatureGroup<T>,
public fun <T : Any> Modifier.canvasControls(
state: CanvasState<T>,
features: FeatureStore<T>,
): Modifier = with(state) {
// //selecting all tapabales ahead of time
@@ -32,8 +32,8 @@ public fun <T : Any> Modifier.mapControls(
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val coordinates = event.changes.first().position.toCoordinates(this)
val point = space.ViewPoint(coordinates, zoom)
val coordinates = toCoordinates(event.changes.first().position, this)
val point = state.space.ViewPoint(coordinates, zoom)
if (event.type == PointerEventType.Move) {
features.forEachWithAttribute(HoverListenerAttribute) { _, feature, listeners ->
@@ -47,9 +47,9 @@ public fun <T : Any> Modifier.mapControls(
}
}.pointerInput(Unit) {
detectClicks(
onDoubleClick = if (state.config.zoomOnDoubleClick) {
onDoubleClick = if (viewConfig.zoomOnDoubleClick) {
{ event ->
val invariant = event.position.toCoordinates(this)
val invariant = toCoordinates(event.position, this)
viewPoint = with(space) {
viewPoint.zoomBy(
if (event.buttons.isPrimaryPressed) 1f else if (event.buttons.isSecondaryPressed) -1f else 0f,
@@ -59,15 +59,15 @@ public fun <T : Any> Modifier.mapControls(
}
} else null,
onClick = { event ->
val coordinates = event.position.toCoordinates(this)
val coordinates = toCoordinates(event.position, this)
val point = space.ViewPoint(coordinates, zoom)
config.onClick?.handle(
viewConfig.onClick?.handle(
event,
point
)
features.forEachWithAttributeUntil(ClickListenerAttribute) { _, feature, listeners ->
features.forEachWithAttributeUntil(ClickListenerAttribute) {_, feature, listeners ->
if (point in (feature as DomainFeature)) {
listeners.forEach { it.handle(event, point) }
false
@@ -88,7 +88,7 @@ public fun <T : Any> Modifier.mapControls(
//compute invariant point of translation
val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toCoordinates()
viewPoint = with(space) {
viewPoint.zoomBy(-change.scrollDelta.y * config.zoomSpeed, invariant)
viewPoint.zoomBy(-change.scrollDelta.y * viewConfig.zoomSpeed, invariant)
}
change.consume()
}
@@ -110,14 +110,14 @@ public fun <T : Any> Modifier.mapControls(
//apply drag handle and check if it prohibits the drag even propagation
if (selectionStart == null) {
val dragStart = space.ViewPoint(
dragChange.previousPosition.toCoordinates(this),
toCoordinates(dragChange.previousPosition, this),
zoom
)
val dragEnd = space.ViewPoint(
dragChange.position.toCoordinates(this),
toCoordinates(dragChange.position, this),
zoom
)
val dragResult = config.dragHandle?.handle(event, dragStart, dragEnd)
val dragResult = viewConfig.dragHandle?.handle(event, dragStart, dragEnd)
if (dragResult?.handleNext == false) return@drag
var continueAfter = true
@@ -132,7 +132,7 @@ public fun <T : Any> Modifier.mapControls(
}
if (event.buttons.isPrimaryPressed) {
//If selection process is started, modify the frame
//If the selection process is started, modify the frame
selectionStart?.let { start ->
val offset = dragChange.position
selectRect = DpRect(
@@ -161,8 +161,8 @@ public fun <T : Any> Modifier.mapControls(
rect.topLeft.toCoordinates(),
rect.bottomRight.toCoordinates()
)
config.onSelect(coordinateRect)
if (config.zoomOnSelect) {
viewConfig.onSelect(coordinateRect)
if (viewConfig.zoomOnSelect) {
viewPoint = computeViewPoint(coordinateRect)
}
selectRect = null

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.compose
package space.kscience.maps.compose
import androidx.compose.foundation.gestures.GestureCancellationException
import androidx.compose.foundation.gestures.PressGestureScope
@@ -26,32 +26,9 @@ public val PointerEvent.position: Offset get() = firstChange.position
/**
* Detects tap, double-tap, and long press gestures and calls [onClick], [onDoubleClick], and
* [onLongClick], respectively, when detected. [onPress] is called when the press is detected
* and the [PressGestureScope.tryAwaitRelease] and [PressGestureScope.awaitRelease] can be
* used to detect when pointers have released or the gesture was canceled.
* The first pointer down and final pointer up are consumed, and in the
* case of long press, all changes after the long press is detected are consumed.
*
* Each function parameter receives an [Offset] representing the position relative to the containing
* element. The [Offset] can be outside the actual bounds of the element itself meaning the numbers
* can be negative or larger than the element bounds if the touch target is smaller than the
* [ViewConfiguration.minimumTouchTargetSize].
*
* When [onDoubleClick] is provided, the tap gesture is detected only after
* the [ViewConfiguration.doubleTapMinTimeMillis] has passed and [onDoubleClick] is called if the
* second tap is started before [ViewConfiguration.doubleTapTimeoutMillis]. If [onDoubleClick] is not
* provided, then [onClick] is called when the pointer up has been received.
*
* After the initial [onPress], if the pointer moves out of the input area, the position change
* is consumed, or another gesture consumes the down or up events, the gestures are considered
* canceled. That means [onDoubleClick], [onLongClick], and [onClick] will not be called after a
* gesture has been canceled.
*
* If the first down event is consumed somewhere else, the entire gesture will be skipped,
* including [onPress].
* An alternative to [detectTapGestures] with reimplementation of internal logic
*/
public suspend fun PointerInputScope.detectClicks(
internal suspend fun PointerInputScope.detectClicks(
onDoubleClick: (Density.(PointerEvent) -> Unit)? = null,
onLongClick: (Density.(PointerEvent) -> Unit)? = null,
onPress: suspend PressGestureScope.(event: PointerEvent) -> Unit = NoPressGesture,

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.features
package space.kscience.maps.features
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
@@ -7,10 +7,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.*
public abstract class CoordinateViewScope<T : Any>(
public val config: ViewConfig<T>,
) {
/**
* A state holder for current canvas size and view point. Allows transformation from coordinates to pixels and back
*/
public abstract class CanvasState<T: Any>(
public val viewConfig: ViewConfig<T>
){
public abstract val space: CoordinateSpace<T>
private var canvasSizeState: MutableState<DpSize?> = mutableStateOf(null)
@@ -20,13 +22,14 @@ public abstract class CoordinateViewScope<T : Any>(
get() = canvasSizeState.value ?: DpSize(512.dp, 512.dp)
set(value) {
canvasSizeState.value = value
viewConfig.onCanvasSizeChange(value)
}
public var viewPoint: ViewPoint<T>
get() = viewPointState.value ?: space.defaultViewPoint
set(value) {
viewPointState.value = value
config.onViewChange(viewPoint)
viewConfig.onViewChange(viewPoint)
}
public val zoom: Float get() = viewPoint.zoom
@@ -35,28 +38,28 @@ public abstract class CoordinateViewScope<T : Any>(
// Selection rectangle. If null - no selection
public var selectRect: DpRect? by mutableStateOf(null)
public abstract fun DpOffset.toCoordinates(): T
public abstract fun T.toDpOffset(): DpOffset
public fun T.toOffset(density: Density): Offset = with(density) {
val dpOffset = this@toOffset.toDpOffset()
Offset(dpOffset.x.toPx(), dpOffset.y.toPx())
}
public fun Offset.toCoordinates(density: Density): T = with(density) {
val dpOffset = DpOffset(x.toDp(), y.toDp())
dpOffset.toCoordinates()
}
public abstract fun Rectangle<T>.toDpRect(): DpRect
public abstract fun ViewPoint<T>.moveBy(x: Dp, y: Dp): ViewPoint<T>
public abstract fun computeViewPoint(rectangle: Rectangle<T>): ViewPoint<T>
}
public abstract fun DpOffset.toCoordinates(): T
public abstract fun T.toDpOffset(): DpOffset
public fun toCoordinates(offset: Offset, density: Density): T = with(density){
val dpOffset = DpOffset(offset.x.toDp(), offset.y.toDp())
dpOffset.toCoordinates()
}
public fun toOffset(coordinates: T, density: Density): Offset = with(density){
val dpOffset = coordinates.toDpOffset()
return Offset(dpOffset.x.toPx(), dpOffset.y.toPx())
}
}
public val DpRect.topLeft: DpOffset get() = DpOffset(left, top)

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.features
package space.kscience.maps.features
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.features
package space.kscience.maps.features
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.isPrimaryPressed

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.features
package space.kscience.maps.features
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
@@ -17,14 +17,14 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.attributes.Attributes
import center.sciprog.attributes.NameAttribute
import org.jetbrains.skia.Font
import space.kscience.attributes.Attributes
import space.kscience.kmath.geometry.Angle
import space.kscience.kmath.nd.Structure2D
public typealias FloatRange = ClosedFloatingPointRange<Float>
public interface Feature<T : Any> {
public sealed interface Feature<T : Any> {
public val space: CoordinateSpace<T>
@@ -35,6 +35,11 @@ public interface Feature<T : Any> {
public fun withAttributes(modify: Attributes.() -> Attributes): Feature<T>
}
/**
* A feature that is not processed by default
*/
public interface CustomFeature<T : Any> : Feature<T>
public val Feature<*>.color: Color? get() = attributes[ColorAttribute]
public val Feature<*>.zoomRange: FloatRange
@@ -43,25 +48,25 @@ public val Feature<*>.zoomRange: FloatRange
public val Feature<*>.name: String?
get() = attributes[NameAttribute]
public interface PainterFeature<T : Any> : Feature<T> {
public sealed interface PainterFeature<T : Any> : Feature<T> {
@Composable
public fun getPainter(): Painter
}
public interface DomainFeature<T : Any> : Feature<T> {
public sealed interface DomainFeature<T : Any> : Feature<T> {
public operator fun contains(viewPoint: ViewPoint<T>): Boolean = getBoundingBox(viewPoint.zoom)?.let {
viewPoint.focus in it
} ?: false
}
public interface DraggableFeature<T : Any> : DomainFeature<T> {
public sealed interface DraggableFeature<T : Any> : DomainFeature<T> {
public fun withCoordinates(newCoordinates: T): Feature<T>
}
/**
* A draggable marker feature. Other features could be bound to this one.
*/
public interface MarkerFeature<T : Any> : DraggableFeature<T> {
public sealed interface MarkerFeature<T : Any> : DraggableFeature<T> {
public val center: T
}
@@ -128,7 +133,7 @@ public data class PointsFeature<T : Any>(
}
public interface LineSegmentFeature<T : Any> : Feature<T>
public sealed interface LineSegmentFeature<T : Any> : Feature<T>
@Stable
public data class LineFeature<T : Any>(
@@ -309,7 +314,7 @@ public data class VectorIconFeature<T : Any>(
/**
* An image that is bound to coordinates and is scaled (and possibly warped) together with them
*
* @param rectangle the size of background in scheme size units. The screen units to scheme units ratio equals scale.
* @param rectangle the size of the image in scheme size units. The screen units to scheme units ratio equals scale.
*/
public data class ScalableImageFeature<T : Any>(
override val space: CoordinateSpace<T>,
@@ -331,7 +336,7 @@ public data class TextFeature<T : Any>(
public val position: T,
public val text: String,
override val attributes: Attributes = Attributes.EMPTY,
public val fontConfig: FeatureFont.() -> Unit,
public val fontConfig: Font.() -> Unit,
) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Float): Rectangle<T> = space.Rectangle(position, position)

View File

@@ -0,0 +1,156 @@
package space.kscience.maps.features
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.DrawScopeMarker
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.DpRect
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.sample
import space.kscience.attributes.Attributes
import space.kscience.attributes.plus
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/**
* An extension of [DrawScope] to include map-specific features
*/
@DrawScopeMarker
public abstract class FeatureDrawScope<T : Any>(
public val state: CanvasState<T>,
) : DrawScope {
public fun Offset.toCoordinates(): T = with(state) {
toCoordinates(this@toCoordinates, this@FeatureDrawScope)
}
public open fun T.toOffset(): Offset = with(state) {
toOffset(this@toOffset, this@FeatureDrawScope)
}
public fun Rectangle<T>.toDpRect(): DpRect = with(state) { toDpRect() }
public abstract fun painterFor(feature: PainterFeature<T>): Painter
public abstract fun drawText(text: String, position: Offset, attributes: Attributes)
public companion object {
public val logger: KLogger = KotlinLogging.logger("FeatureDrawScope")
}
}
/**
* Default implementation of FeatureDrawScope to be used in Compose (both schemes and Maps)
*/
@DrawScopeMarker
public class ComposeFeatureDrawScope<T : Any>(
drawScope: DrawScope,
state: CanvasState<T>,
private val painterCache: Map<PainterFeature<T>, Painter>,
private val textMeasurer: TextMeasurer?,
) : FeatureDrawScope<T>(state), DrawScope by drawScope {
override fun drawText(text: String, position: Offset, attributes: Attributes) {
if (position.x in 0f..size.width && position.y in 0f..size.height) {
try {
drawText(textMeasurer ?: error("Text measurer not defined"), text, position)
} catch (ex: Exception) {
logger.error(ex) { "Failed to measure text" }
}
}
}
override fun painterFor(feature: PainterFeature<T>): Painter =
painterCache[feature] ?: error("Can't resolve painter for $feature")
public companion object {
private val logger = KotlinLogging.logger("ComposeFeatureDrawScope")
}
}
@Composable
public fun <T : Any> FeatureSet<T>.pointerCache(): Map<PainterFeature<T>, Painter> = key(features) {
features.values.filterIsInstance<PainterFeature<T>>().associateWith { it.getPainter() }
}
/**
* Create a canvas with extended functionality (e.g., drawing text)
*
* @param draw additional custom content drawn underneath features
*/
@OptIn(FlowPreview::class)
@Composable
public fun <T : Any> FeatureCanvas(
state: CanvasState<T>,
featureFlow: StateFlow<Map<String, Feature<T>>>,
modifier: Modifier = Modifier,
sampleDuration: Duration = 20.milliseconds,
draw: FeatureDrawScope<T>.() -> Unit = {},
) {
val textMeasurer = rememberTextMeasurer(0)
val features: Map<String, Feature<T>> by featureFlow.sample(sampleDuration).collectAsState(featureFlow.value)
val painterCache = features.values
.filterIsInstance<PainterFeature<T>>()
.associateWith { it.getPainter() }
Canvas(modifier) {
if (state.canvasSize != size.toDpSize()) {
state.canvasSize = size.toDpSize()
}
clipRect {
ComposeFeatureDrawScope(this, state, painterCache, textMeasurer).apply(draw).apply {
val attributesCache = mutableMapOf<List<String>, Attributes>()
fun computeGroupAttributes(path: List<String>): Attributes = attributesCache.getOrPut(path) {
if (path.isEmpty()) return Attributes.EMPTY
else if (path.size == 1) {
features[path.first()]?.attributes ?: Attributes.EMPTY
} else {
computeGroupAttributes(path.dropLast(1)) + (features[path.first()]?.attributes
?: Attributes.EMPTY)
}
}
features.entries.sortedBy { it.value.z }
.filter { state.viewPoint.zoom in it.value.zoomRange }
.forEach { (id, feature) ->
val path = id.split("/")
drawFeature(feature, computeGroupAttributes(path.dropLast(1)))
}
}
}
state.selectRect?.let { dpRect ->
val rect = dpRect.toRect()
drawRect(
color = Color.Blue,
topLeft = rect.topLeft,
size = rect.size,
alpha = 0.5f,
style = Stroke(
width = 2f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
)
)
}
}
}

View File

@@ -0,0 +1,415 @@
@file:OptIn(ExperimentalUuidApi::class)
package space.kscience.maps.features
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.jetbrains.skia.Font
import space.kscience.attributes.Attribute
import space.kscience.attributes.Attributes
import space.kscience.kmath.geometry.Angle
import space.kscience.kmath.nd.*
import space.kscience.kmath.structures.Buffer
import space.kscience.maps.features.FeatureStore.Companion.generateFeatureId
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
//@JvmInline
//public value class FeatureId<out F : Feature<*>>(public val id: String)
/**
* A reference to a feature inside a [FeatureStore]
*/
public class FeatureRef<T : Any, out F : Feature<T>> internal constructor(
internal val store: FeatureStore<T>,
internal val id: String,
) {
override fun toString(): String = "FeatureRef($id)"
}
@Suppress("UNCHECKED_CAST")
public fun <T : Any, F : Feature<T>> FeatureRef<T, F>.resolve(): F =
store.features[id]?.let { it as F } ?: error("Feature with ref $this not found")
public val <T : Any, F : Feature<T>> FeatureRef<T, F>.attributes: Attributes get() = resolve().attributes
public interface FeatureBuilder<T : Any> {
public val space: CoordinateSpace<T>
/**
* Add or replace feature. If [id] is null, then a unique id is generated
*/
public fun <F : Feature<T>> feature(id: String?, feature: F): FeatureRef<T, F>
public fun putFeatures(features: Map<String, Feature<T>?>)
/**
* Update existing feature if it is present and is of type [F]
*/
public fun <F : Feature<T>> updateFeature(id: String, block: (F?) -> F): FeatureRef<T, F>
public fun group(
id: String? = null,
attributes: Attributes = Attributes.EMPTY,
builder: FeatureGroup<T>.() -> Unit,
): FeatureRef<T, FeatureGroup<T>>
public fun removeFeature(id: String)
}
public interface FeatureSet<T : Any> {
public val features: Map<String, Feature<T>>
/**
* Create a reference
*/
public fun <F : Feature<T>> ref(id: String): FeatureRef<T, F>
}
/**
* A class representing a store for managing spatial features with the ability to add, update,
* remove, and group features within a specific coordinate space.
* It also provides reactive flow support to observe changes in the feature set.
*
* @param T The type parameter representing the coordinate points handled by the store.
* @property space The coordinate space associated with this feature store, enabling operations
* on map coordinates.
*/
public class FeatureStore<T : Any>(
override val space: CoordinateSpace<T>,
) : CoordinateSpace<T> by space, FeatureBuilder<T>, FeatureSet<T> {
private val _featureFlow = MutableStateFlow<Map<String, Feature<T>>>(emptyMap())
public val featureFlow: StateFlow<Map<String, Feature<T>>> get() = _featureFlow
override val features: Map<String, Feature<T>> get() = featureFlow.value
override fun <F : Feature<T>> feature(id: String?, feature: F): FeatureRef<T, F> {
val safeId = id ?: generateFeatureId(feature)
_featureFlow.value += (safeId to feature)
return FeatureRef(this, safeId)
}
public override fun putFeatures(features: Map<String, Feature<T>?>) {
_featureFlow.value = _featureFlow.value.toMutableMap().apply {
features.forEach { (key, value) ->
if (value == null) {
remove(key)
} else {
put(key, value)
}
}
}
}
@Suppress("UNCHECKED_CAST")
override fun <F : Feature<T>> updateFeature(id: String, block: (F?) -> F): FeatureRef<T, F> =
feature(id, block(features[id] as? F))
override fun group(
id: String?,
attributes: Attributes,
builder: FeatureGroup<T>.() -> Unit,
): FeatureRef<T, FeatureGroup<T>> {
val safeId: String = id ?: generateFeatureId<FeatureGroup<*>>()
return feature(safeId, FeatureGroup(this, safeId, attributes).apply(builder))
}
override fun removeFeature(id: String) {
_featureFlow.value -= id
}
override fun <F : Feature<T>> ref(id: String): FeatureRef<T, F> = FeatureRef(this, id)
public fun getBoundingBox(zoom: Float = Float.MAX_VALUE): Rectangle<T>? = with(space) {
features.values.mapNotNull { it.getBoundingBox(zoom) }.wrapRectangles()
}
public companion object {
internal fun generateFeatureId(prefix: String): String =
"$prefix[${Uuid.random().toHexString()}]"
internal fun generateFeatureId(feature: Feature<*>): String =
generateFeatureId(feature::class.simpleName ?: "undefined")
internal inline fun <reified F : Feature<*>> generateFeatureId(): String =
generateFeatureId(F::class.simpleName ?: "undefined")
/**
* Build, but do not remember map feature state
*/
public fun <T : Any> build(
coordinateSpace: CoordinateSpace<T>,
builder: FeatureStore<T>.() -> Unit = {},
): FeatureStore<T> = FeatureStore(coordinateSpace).apply(builder)
/**
* Build and remember map feature state
*/
@Composable
public fun <T : Any> remember(
coordinateSpace: CoordinateSpace<T>,
builder: FeatureStore<T>.() -> Unit = {},
): FeatureStore<T> = remember {
build(coordinateSpace, builder)
}
}
}
/**
* A group of other features
*/
public class FeatureGroup<T : Any> internal constructor(
public val store: FeatureStore<T>,
public val groupId: String,
override val attributes: Attributes,
) : CoordinateSpace<T> by store.space, Feature<T>, FeatureBuilder<T>, FeatureSet<T> {
override val space: CoordinateSpace<T> get() = store.space
override fun withAttributes(modify: Attributes.() -> Attributes): FeatureGroup<T> =
FeatureGroup(store, groupId, modify(attributes))
override fun <F : Feature<T>> feature(id: String?, feature: F): FeatureRef<T, F> =
store.feature("$groupId/${id ?: generateFeatureId(feature)}", feature)
public override fun putFeatures(features: Map<String, Feature<T>?>) {
store.putFeatures(features.mapKeys { "$groupId/${it.key}" })
}
override fun <F : Feature<T>> updateFeature(id: String, block: (F?) -> F): FeatureRef<T, F> =
store.updateFeature("$groupId/$id", block)
override fun group(
id: String?,
attributes: Attributes,
builder: FeatureGroup<T>.() -> Unit,
): FeatureRef<T, FeatureGroup<T>> {
val safeId = id ?: generateFeatureId<FeatureGroup<*>>()
return feature(safeId, FeatureGroup(store, "$groupId/$safeId", attributes).apply(builder))
}
override fun removeFeature(id: String) {
store.removeFeature("$groupId/$id")
}
override val features: Map<String, Feature<T>>
get() = store.featureFlow.value
.filterKeys { it.startsWith("$groupId/") }
.mapKeys { it.key.removePrefix("$groupId/") }
.toMap()
override fun getBoundingBox(zoom: Float): Rectangle<T>? = with(space) {
features.values.mapNotNull { it.getBoundingBox(zoom) }.wrapRectangles()
}
override fun <F : Feature<T>> ref(id: String): FeatureRef<T, F> = FeatureRef(store, "$groupId/$id")
}
/**
* Recursively search for feature until function returns true
*/
public fun <T : Any> FeatureSet<T>.forEachUntil(block: FeatureSet<T>.(ref: FeatureRef<T, *>, feature: Feature<T>) -> Boolean) {
features.entries.sortedByDescending { it.value.z }.forEach { (key, feature) ->
if (!block(ref<Feature<T>>(key), feature)) return@forEachUntil
}
}
/**
* Process all features with a given attribute from the one with highest [z] to lowest
*/
public inline fun <T : Any, A> FeatureSet<T>.forEachWithAttribute(
key: Attribute<A>,
block: FeatureSet<T>.(ref: FeatureRef<T, *>, feature: Feature<T>, attribute: A) -> Unit,
) {
features.forEach { (id, feature) ->
feature.attributes[key]?.let {
block(ref<Feature<T>>(id), feature, it)
}
}
}
public inline fun <T : Any, A> FeatureSet<T>.forEachWithAttributeUntil(
key: Attribute<A>,
block: FeatureSet<T>.(ref: FeatureRef<T, *>, feature: Feature<T>, attribute: A) -> Boolean,
) {
features.forEach { (id, feature) ->
feature.attributes[key]?.let {
if (!block(ref<Feature<T>>(id), feature, it)) return@forEachWithAttributeUntil
}
}
}
public inline fun <T : Any, reified F : Feature<T>> FeatureSet<T>.forEachWithType(
crossinline block: FeatureSet<T>.(ref: FeatureRef<T, F>, feature: F) -> Unit,
) {
features.forEach { (id, feature) ->
if (feature is F) block(ref(id), feature)
}
}
public fun <T : Any> FeatureBuilder<T>.circle(
center: T,
size: Dp = 5.dp,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, CircleFeature<T>> = feature(
id, CircleFeature(space, center, size, attributes)
)
public fun <T : Any> FeatureBuilder<T>.rectangle(
centerCoordinates: T,
size: DpSize = DpSize(5.dp, 5.dp),
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, RectangleFeature<T>> = feature(
id, RectangleFeature(space, centerCoordinates, size, attributes)
)
public fun <T : Any> FeatureBuilder<T>.draw(
position: T,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
draw: DrawScope.() -> Unit,
): FeatureRef<T, DrawFeature<T>> = feature(
id,
DrawFeature(space, position, drawFeature = draw, attributes = attributes)
)
public fun <T : Any> FeatureBuilder<T>.line(
aCoordinates: T,
bCoordinates: T,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, LineFeature<T>> = feature(
id,
LineFeature(space, aCoordinates, bCoordinates, attributes)
)
public fun <T : Any> FeatureBuilder<T>.arc(
oval: Rectangle<T>,
startAngle: Angle,
arcLength: Angle,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, ArcFeature<T>> = feature(
id,
ArcFeature(space, oval, startAngle, arcLength, attributes)
)
public fun <T : Any> FeatureBuilder<T>.points(
points: List<T>,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, PointsFeature<T>> = feature(
id,
PointsFeature(space, points, attributes)
)
public fun <T : Any> FeatureBuilder<T>.multiLine(
points: List<T>,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, MultiLineFeature<T>> = feature(
id,
MultiLineFeature(space, points, attributes)
)
public fun <T : Any> FeatureBuilder<T>.polygon(
points: List<T>,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, PolygonFeature<T>> = feature(
id,
PolygonFeature(space, points, attributes)
)
public fun <T : Any> FeatureBuilder<T>.icon(
position: T,
image: ImageVector,
size: DpSize = DpSize(image.defaultWidth, image.defaultHeight),
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, VectorIconFeature<T>> = feature(
id,
VectorIconFeature(
space,
position,
size,
image,
attributes
)
)
public fun <T : Any> FeatureBuilder<T>.scalableImage(
box: Rectangle<T>,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
painter: @Composable () -> Painter,
): FeatureRef<T, ScalableImageFeature<T>> = feature(
id,
ScalableImageFeature<T>(space, box, painter = painter, attributes = attributes)
)
public fun <T : Any> FeatureBuilder<T>.text(
position: T,
text: String,
font: Font.() -> Unit = { size = 16f },
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, TextFeature<T>> = feature(
id,
TextFeature(space, position, text, fontConfig = font, attributes = attributes)
)
//public fun <T> StructureND(shape: ShapeND, initializer: (IntArray) -> T): StructureND<T> {
// val strides = Strides(shape)
// return BufferND(strides, Buffer(strides.linearSize) { initializer(strides.index(it)) })
//}
public inline fun <reified T> Structure2D(rows: Int, columns: Int, initializer: (IntArray) -> T): Structure2D<T> {
val strides = Strides(ShapeND(rows, columns))
return BufferND(strides, Buffer(strides.linearSize) { initializer(strides.index(it)) }).as2D()
}
public fun <T : Any> FeatureStore<T>.pixelMap(
rectangle: Rectangle<T>,
pixelMap: Structure2D<Color?>,
attributes: Attributes = Attributes.EMPTY,
id: String? = null,
): FeatureRef<T, PixelMapFeature<T>> = feature(
id,
PixelMapFeature(space, rectangle, pixelMap, attributes = attributes)
)
/**
* Create a pretty tree-like representation of this feature group
*/
public fun FeatureGroup<*>.toPrettyString(): String {
fun StringBuilder.printGroup(id: String, group: FeatureGroup<*>, prefix: String) {
appendLine("${prefix}* [group] $id")
group.features.forEach { (id, feature) ->
if (feature is FeatureGroup<*>) {
printGroup(id, feature, " ")
} else {
appendLine("$prefix * [${feature::class.simpleName}] $id ")
}
}
}
return buildString {
printGroup("root", this@toPrettyString, "")
}
}

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.features
package space.kscience.maps.features
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.isPrimaryPressed

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.features
package space.kscience.maps.features
import androidx.compose.ui.unit.DpSize

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.features
package space.kscience.maps.features
/**
* @param T type of coordinates used for the view point

View File

@@ -0,0 +1,66 @@
package space.kscience.maps.features
import space.kscience.attributes.Attributes
import kotlin.jvm.JvmName
public fun <T : Any> FeatureBuilder<T>.draggableLine(
aId: FeatureRef<T, MarkerFeature<T>>,
bId: FeatureRef<T, MarkerFeature<T>>,
id: String? = null,
): FeatureRef<T, LineFeature<T>> {
val lineId = id ?: FeatureStore.generateFeatureId<LineFeature<*>>()
fun drawLine(): FeatureRef<T, LineFeature<T>> = updateFeature(lineId) { old ->
LineFeature(
space,
aId.resolve().center,
bId.resolve().center,
old?.attributes ?: Attributes(ZAttribute, -10f)
)
}
aId.draggable { _, _ ->
drawLine()
}
bId.draggable { _, _ ->
drawLine()
}
return drawLine()
}
public fun <T : Any> FeatureBuilder<T>.draggableMultiLine(
points: List<FeatureRef<T, MarkerFeature<T>>>,
id: String? = null,
): FeatureRef<T, MultiLineFeature<T>> {
val polygonId = id ?: FeatureStore.generateFeatureId("multiline")
fun drawLines(): FeatureRef<T, MultiLineFeature<T>> = updateFeature(polygonId) { old ->
MultiLineFeature(
space,
points.map { it.resolve().center },
old?.attributes ?: Attributes(ZAttribute, -10f)
)
}
points.forEach {
it.draggable { _, _ ->
drawLines()
}
}
return drawLines()
}
@JvmName("draggableMultiLineFromPoints")
public fun <T : Any> FeatureBuilder<T>.draggableMultiLine(
points: List<T>,
id: String? = null,
): FeatureRef<T, MultiLineFeature<T>> {
val pointRefs = points.map {
circle(it)
}
return draggableMultiLine(pointRefs, id)
}

View File

@@ -1,37 +1,42 @@
package center.sciprog.maps.features
package space.kscience.maps.features
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.*
import androidx.compose.ui.graphics.painter.Painter
import center.sciprog.attributes.plus
import org.jetbrains.skia.Font
import org.jetbrains.skia.Paint
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import space.kscience.attributes.Attributes
import space.kscience.attributes.plus
import space.kscience.kmath.PerformancePitfall
import space.kscience.kmath.geometry.degrees
internal fun Color.toPaint(): Paint = Paint().apply {
isAntiAlias = true
color = toArgb()
}
//internal fun Color.toPaint(): Paint = Paint().apply {
// isAntiAlias = true
// color = toArgb()
//}
public fun <T : Any> DrawScope.drawFeature(
state: CoordinateViewScope<T>,
painterCache: Map<PainterFeature<T>, Painter>,
public fun <T : Any> FeatureDrawScope<T>.drawFeature(
feature: Feature<T>,
): Unit = with(state) {
val color = feature.color ?: Color.Red
val alpha = feature.attributes[AlphaAttribute] ?: 1f
fun T.toOffset(): Offset = toOffset(this@drawFeature)
baseAttributes: Attributes,
): Unit {
val attributes = baseAttributes + feature.attributes
val color = attributes[ColorAttribute] ?: Color.Red
val alpha = attributes[AlphaAttribute] ?: 1f
//avoid drawing invisible features
if (attributes[VisibleAttribute] == false) return
when (feature) {
is FeatureSelector -> drawFeature(state, painterCache, feature.selector(state.zoom))
is FeatureSelector -> drawFeature(feature.selector(state.zoom), attributes)
is CircleFeature -> drawCircle(
color,
feature.radius.toPx(),
center = feature.center.toOffset()
center = feature.center.toOffset(),
alpha = alpha
)
is RectangleFeature -> drawRect(
@@ -40,15 +45,17 @@ public fun <T : Any> DrawScope.drawFeature(
feature.size.width.toPx() / 2,
feature.size.height.toPx() / 2
),
size = feature.size.toSize()
size = feature.size.toSize(),
alpha = alpha
)
is LineFeature -> drawLine(
color,
feature.a.toOffset(),
feature.b.toOffset(),
strokeWidth = feature.attributes[StrokeAttribute] ?: Stroke.HairlineWidth,
pathEffect = feature.attributes[PathEffectAttribute]
strokeWidth = attributes[StrokeAttribute] ?: Stroke.HairlineWidth,
pathEffect = attributes[PathEffectAttribute],
alpha = alpha
)
is ArcFeature -> {
@@ -58,8 +65,8 @@ public fun <T : Any> DrawScope.drawFeature(
drawArc(
color = color,
startAngle = (feature.startAngle.degrees).toFloat(),
sweepAngle = (feature.arcLength.degrees).toFloat(),
startAngle = (feature.startAngle.toDegrees().value).toFloat(),
sweepAngle = (feature.arcLength.toDegrees().value).toFloat(),
useCenter = false,
topLeft = dpRect.topLeft,
size = size,
@@ -69,28 +76,19 @@ public fun <T : Any> DrawScope.drawFeature(
}
is BitmapIconFeature -> drawImage(feature.image, feature.center.toOffset())
is BitmapIconFeature -> drawImage(feature.image, feature.center.toOffset(), alpha = alpha)
is VectorIconFeature -> {
val offset = feature.center.toOffset()
val size = feature.size.toSize()
translate(offset.x - size.width / 2, offset.y - size.height / 2) {
with(painterCache[feature]!!) {
draw(size)
with(this@drawFeature.painterFor(feature)) {
draw(size, colorFilter = feature.color?.let { ColorFilter.tint(it) }, alpha = alpha)
}
}
}
is TextFeature -> drawIntoCanvas { canvas ->
val offset = feature.position.toOffset()
canvas.nativeCanvas.drawString(
feature.text,
offset.x + 5,
offset.y - 5,
Font().apply(feature.fontConfig),
(feature.color ?: Color.Black).toPaint()
)
}
is TextFeature -> drawText(feature.text, feature.position.toOffset(), attributes)
is DrawFeature -> {
val offset = feature.position.toOffset()
@@ -100,11 +98,7 @@ public fun <T : Any> DrawScope.drawFeature(
}
is FeatureGroup -> {
feature.featureMap.values.forEach {
drawFeature(state, painterCache, it.withAttributes {
feature.attributes + this
})
}
//ignore groups
}
is PathFeature -> {
@@ -121,9 +115,9 @@ public fun <T : Any> DrawScope.drawFeature(
drawPoints(
points = points,
color = color,
strokeWidth = feature.attributes[StrokeAttribute] ?: Stroke.HairlineWidth,
strokeWidth = attributes[StrokeAttribute] ?: 5f,
pointMode = PointMode.Points,
pathEffect = feature.attributes[PathEffectAttribute],
pathEffect = attributes[PathEffectAttribute],
alpha = alpha
)
}
@@ -133,9 +127,9 @@ public fun <T : Any> DrawScope.drawFeature(
drawPoints(
points = points,
color = color,
strokeWidth = feature.attributes[StrokeAttribute] ?: Stroke.HairlineWidth,
strokeWidth = attributes[StrokeAttribute] ?: Stroke.HairlineWidth,
pointMode = PointMode.Polygon,
pathEffect = feature.attributes[PathEffectAttribute],
pathEffect = attributes[PathEffectAttribute],
alpha = alpha
)
}
@@ -160,8 +154,8 @@ public fun <T : Any> DrawScope.drawFeature(
val offset = rect.topLeft
translate(offset.x, offset.y) {
with(painterCache[feature]!!) {
draw(rect.size)
with(this@drawFeature.painterFor(feature)) {
draw(rect.size, alpha = alpha)
}
}
}
@@ -184,15 +178,17 @@ public fun <T : Any> DrawScope.drawFeature(
x = i * xStep,
y = rect.height - j * yStep
),
size = pixelSize
size = pixelSize,
alpha = alpha
)
}
}
}
}
else -> {
//logger.error { "Unrecognized feature type: ${feature::class}" }
is CustomFeature<*> -> {
//do nothing
}
}
}

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.features
package space.kscience.maps.features
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.PointerMatcher
@@ -6,13 +6,17 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerKeyboardModifiers
import center.sciprog.attributes.Attribute
import center.sciprog.attributes.AttributesBuilder
import center.sciprog.attributes.SetAttribute
import center.sciprog.attributes.withAttribute
import kotlinx.serialization.builtins.serializer
import space.kscience.attributes.*
import space.kscience.attributes.serialization.SerializableAttribute
public object NameAttribute : SerializableAttribute<String>("name", String.serializer())
public object ZAttribute : Attribute<Float>
public val Feature<*>.z: Float
get() = attributes[ZAttribute] ?: 0f
public object DraggableAttribute : Attribute<DragHandle<Any>>
public object DragListenerAttribute : SetAttribute<DragListener<Any>>
@@ -46,23 +50,28 @@ public fun <T : Any, F : Feature<T>> FeatureRef<T, F>.zoomRange(range: FloatRang
public object AlphaAttribute : Attribute<Float>
public fun <T : Any, F : Feature<T>> FeatureRef<T, F>.modifyAttributes(modify: AttributesBuilder.() -> Unit): FeatureRef<T, F> {
public fun <T : Any, F : Feature<T>> FeatureRef<T, F>.alpha(alpha: Float): FeatureRef<T, F> {
require(alpha in 0f..1f) { "Alpha value must be between 0 and 1" }
return modifyAttribute(AlphaAttribute, alpha)
}
public fun <T : Any, F : Feature<T>> FeatureRef<T, F>.modifyAttributes(
modification: AttributesBuilder<F>.() -> Unit,
): FeatureRef<T, F> {
@Suppress("UNCHECKED_CAST")
parent.feature(
store.feature(
id,
resolve().withAttributes {
AttributesBuilder(this).apply(modify).build()
} as F
resolve().withAttributes { modified(modification) } as F
)
return this
}
public fun <T : Any, F : Feature<T>, V> FeatureRef<T, F>.modifyAttribute(
key: Attribute<V>,
value: V?,
value: V,
): FeatureRef<T, F> {
@Suppress("UNCHECKED_CAST")
parent.feature(id, resolve().withAttributes { withAttribute(key, value) } as F)
store.feature(id, resolve().withAttributes { withAttribute(key, value) } as F)
return this
}
@@ -75,10 +84,10 @@ public fun <T : Any, F : Feature<T>, V> FeatureRef<T, F>.modifyAttribute(
public fun <T : Any, F : DraggableFeature<T>> FeatureRef<T, F>.draggable(
constraint: ((T) -> T)? = null,
listener: (PointerEvent.(from: ViewPoint<T>, to: ViewPoint<T>) -> Unit)? = null,
): FeatureRef<T, F> = with(parent) {
): FeatureRef<T, F> = with(store) {
if (attributes[DraggableAttribute] == null) {
val handle = DragHandle.withPrimaryButton<Any> { event, start, end ->
val feature = featureMap[id] as? DraggableFeature<T> ?: return@withPrimaryButton DragResult(end)
val feature = features[id] as? DraggableFeature<T> ?: return@withPrimaryButton DragResult(end)
start as ViewPoint<T>
end as ViewPoint<T>
if (start in feature) {
@@ -169,4 +178,7 @@ public fun <T : Any> FeatureRef<T, LineSegmentFeature<T>>.pathEffect(effect: Pat
public object StrokeAttribute : Attribute<Float>
public fun <T : Any, F : LineSegmentFeature<T>> FeatureRef<T, F>.stroke(width: Float): FeatureRef<T, F> =
modifyAttribute(StrokeAttribute, width)
public fun <T : Any, F : PointsFeature<T>> FeatureRef<T, F>.pointSize(width: Float): FeatureRef<T, F> =
modifyAttribute(StrokeAttribute, width)

View File

@@ -1,13 +1,22 @@
package center.sciprog.attributes
import kotlinx.serialization.*
import kotlinx.serialization.Contextual
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import kotlinx.serialization.protobuf.ProtoBuf
import kotlinx.serialization.serializer
import space.kscience.attributes.Attributes
import space.kscience.attributes.serialization.AttributesSerializer
import space.kscience.attributes.serialization.SerializableAttribute
import space.kscience.maps.features.NameAttribute
import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
internal class AttributesSerializationTest {
@@ -28,32 +37,36 @@ internal class AttributesSerializationTest {
override fun toString(): String = "test"
}
val serializer = AttributesSerializer(setOf(NameAttribute, TestAttribute, ContainerAttribute))
@Test
fun restoreFromJson() {
val json = Json {
serializersModule = SerializersModule {
contextual(AttributesSerializer(setOf(NameAttribute, TestAttribute, ContainerAttribute)))
contextual(serializer)
}
}
val attributes = Attributes {
val attributes = Attributes<Any> {
NameAttribute("myTest")
TestAttribute(mapOf("a" to "aa", "b" to "bb"))
ContainerAttribute(
Container(
Attributes {
Attributes<Any> {
TestAttribute(mapOf("a" to "aa", "b" to "bb"))
}
)
)
}
val serialized: String = json.encodeToString(attributes)
val serialized: String = json.encodeToString(serializer, attributes)
println(serialized)
val restored: Attributes = json.decodeFromString(serialized)
val restored: Attributes = json.decodeFromString(serializer, serialized)
assertEquals(attributes, restored)
assertTrue { Attributes.equals(attributes, restored) }
}
@OptIn(ExperimentalSerializationApi::class)
@@ -62,25 +75,25 @@ internal class AttributesSerializationTest {
fun restoreFromProtoBuf() {
val protoBuf = ProtoBuf {
serializersModule = SerializersModule {
contextual(AttributesSerializer(setOf(NameAttribute, TestAttribute, ContainerAttribute)))
contextual(serializer)
}
}
val attributes = Attributes {
val attributes = Attributes<Any> {
NameAttribute("myTest")
TestAttribute(mapOf("a" to "aa", "b" to "bb"))
ContainerAttribute(
Container(
Attributes {
Attributes<Any> {
TestAttribute(mapOf("a" to "aa", "b" to "bb"))
}
)
)
}
val serialized = protoBuf.encodeToByteArray(attributes)
val serialized = protoBuf.encodeToByteArray(serializer, attributes)
val restored: Attributes = protoBuf.decodeFromByteArray(serialized)
val restored: Attributes = protoBuf.decodeFromByteArray(serializer, serialized)
assertEquals(attributes, restored)
}

View File

@@ -1,5 +0,0 @@
package center.sciprog.maps.features
public actual class FeatureFont {
public actual var size: Float = 16f
}

View File

@@ -1,5 +0,0 @@
package center.sciprog.maps.features
import org.jetbrains.skia.Font
public actual typealias FeatureFont = Font

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.svg
package space.kscience.maps.svg
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
@@ -301,7 +301,7 @@ internal class SvgCanvas(val graphics: SVGGraphics2D) : Canvas {
}
internal class SvgDrawContext(val graphics: SVGGraphics2D, override var size: Size) : DrawContext {
override val canvas: Canvas = SvgCanvas(graphics)
override var canvas: Canvas = SvgCanvas(graphics)
override val transform: DrawTransform = asDrawTransform()
}

View File

@@ -1,25 +1,34 @@
package center.sciprog.maps.svg
package space.kscience.maps.svg
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.*
import androidx.compose.ui.graphics.drawscope.DrawContext
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import org.jfree.svg.SVGGraphics2D
import space.kscience.attributes.Attributes
import space.kscience.maps.features.CanvasState
import space.kscience.maps.features.ColorAttribute
import space.kscience.maps.features.FeatureDrawScope
import space.kscience.maps.features.PainterFeature
import java.awt.BasicStroke
import java.awt.Font
import java.awt.geom.*
import java.awt.image.AffineTransformOp
import java.awt.Color as AWTColor
public class SvgDrawScope(
public class SvgDrawScope<T: Any>(
state: CanvasState<T>,
private val graphics: SVGGraphics2D,
size: Size,
private val painterCache: Map<PainterFeature<T>, Painter>,
private val defaultStrokeWidth: Float = 1f,
) : DrawScope {
) : FeatureDrawScope<T>(state) {
override val layoutDirection: LayoutDirection
get() = LayoutDirection.Ltr
@@ -459,18 +468,22 @@ public class SvgDrawScope(
}
}
public fun drawText(
text: String,
x: Float,
y: Float,
font: Font,
color: Color,
) {
setupColor(color)
graphics.font = font
graphics.drawString(text, x, y)
// public fun renderText(
// textFeature: TextFeature<T>,
// ) {
// textFeature.color?.let { setupColor(it) }
// graphics.drawString(textFeature.text, textFeature.position.x, textFeature.position.y)
// }
override fun painterFor(feature: PainterFeature<T>): Painter {
return painterCache[feature]!!
}
override val drawContext: DrawContext = SvgDrawContext(graphics, size)
override fun drawText(text: String, position: Offset, attributes: Attributes) {
attributes[ColorAttribute]?.let { setupColor(it) }
graphics.drawString(text, position.x, position.y)
}
override val drawContext: DrawContext = SvgDrawContext(graphics, state.canvasSize.toSize())
}

View File

@@ -0,0 +1,34 @@
package space.kscience.maps.svg
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import space.kscience.maps.features.CanvasState
import space.kscience.maps.features.ComposeFeatureDrawScope
import space.kscience.maps.features.FeatureSet
import space.kscience.maps.features.PainterFeature
public fun <T : Any> FeatureSet<T>.generateBitmap(
canvasState: CanvasState<T>,
painterCache: Map<PainterFeature<T>, Painter>,
textMeasurer: TextMeasurer,
size: Size
): ImageBitmap {
val bitmap = ImageBitmap(size.width.toInt(), size.height.toInt())
CanvasDrawScope().draw(
density = Density(1f),
layoutDirection = LayoutDirection.Ltr,
canvas = Canvas(bitmap),
size = size,
) {
ComposeFeatureDrawScope(this, canvasState, painterCache, textMeasurer).features(this@generateBitmap)
}
return bitmap
}

View File

@@ -0,0 +1,45 @@
package space.kscience.maps.svg
import androidx.compose.ui.graphics.painter.Painter
import org.jfree.svg.SVGGraphics2D
import space.kscience.attributes.Attributes
import space.kscience.attributes.plus
import space.kscience.maps.features.*
public fun <T : Any> FeatureDrawScope<T>.features(featureSet: FeatureSet<T>) {
featureSet.features.entries.sortedBy { it.value.z }
.filter { state.viewPoint.zoom in it.value.zoomRange }
.forEach { (id, feature) ->
val attributesCache = mutableMapOf<List<String>, Attributes>()
fun computeGroupAttributes(path: List<String>): Attributes = attributesCache.getOrPut(path) {
if (path.isEmpty()) return Attributes.EMPTY
else if (path.size == 1) {
featureSet.features[path.first()]?.attributes ?: Attributes.EMPTY
} else {
computeGroupAttributes(path.dropLast(1)) +
(featureSet.features[path.first()]?.attributes ?: Attributes.EMPTY)
}
}
val path = id.split("/")
drawFeature(feature, computeGroupAttributes(path.dropLast(1)))
}
}
public fun <T : Any> FeatureSet<T>.generateSvg(
canvasState: CanvasState<T>,
painterCache: Map<PainterFeature<T>, Painter>,
id: String? = null,
): String {
val svgGraphics2D: SVGGraphics2D = SVGGraphics2D(
canvasState.canvasSize.width.value.toDouble(),
canvasState.canvasSize.height.value.toDouble()
)
val svgScope = SvgDrawScope(canvasState, svgGraphics2D, painterCache)
svgScope.features(this)
return svgGraphics2D.getSVGElement(id)
}

View File

@@ -1,24 +1,13 @@
# Module maps-kt-geojson
GeoJson format support
## Usage
## Artifact:
The Maven coordinates of this project are `center.sciprog:maps-kt-geojson:0.2.2`.
The Maven coordinates of this project are `space.kscience:maps-kt-geojson:0.4.0-dev-7`.
**Gradle Groovy:**
```groovy
repositories {
maven { url 'https://repo.kotlin.link' }
mavenCentral()
}
dependencies {
implementation 'center.sciprog:maps-kt-geojson:0.2.2'
}
```
**Gradle Kotlin DSL:**
```kotlin
repositories {
@@ -27,6 +16,6 @@ repositories {
}
dependencies {
implementation("center.sciprog:maps-kt-geojson:0.2.2")
implementation("space.kscience:maps-kt-geojson:0.4.0-dev-7")
}
```

View File

@@ -3,10 +3,13 @@ plugins {
`maven-publish`
}
description = "GeoJson format support"
kscience{
jvm()
js()
// js()
wasm()
useSerialization {
json()
}
@@ -15,4 +18,8 @@ kscience{
api(projects.mapsKtFeatures)
api(spclibs.kotlinx.serialization.json)
}
}
readme {
maturity = space.kscience.gradle.Maturity.DEVELOPMENT
}

View File

@@ -1,10 +1,10 @@
package center.sciprog.maps.geojson
package space.kscience.maps.geojson
import center.sciprog.maps.geojson.GeoJson.Companion.PROPERTIES_KEY
import center.sciprog.maps.geojson.GeoJson.Companion.TYPE_KEY
import center.sciprog.maps.geojson.GeoJsonFeatureCollection.Companion.FEATURES_KEY
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
import space.kscience.maps.geojson.GeoJson.Companion.PROPERTIES_KEY
import space.kscience.maps.geojson.GeoJson.Companion.TYPE_KEY
import space.kscience.maps.geojson.GeoJsonFeatureCollection.Companion.FEATURES_KEY
import kotlin.jvm.JvmInline
/**

View File

@@ -1,10 +1,9 @@
package center.sciprog.maps.geojson
package space.kscience.maps.geojson
import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.coordinates.meters
import center.sciprog.maps.geojson.GeoJsonGeometry.Companion.COORDINATES_KEY
import kotlinx.serialization.json.*
import space.kscience.kmath.geometry.degrees
import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.coordinates.meters
import space.kscience.maps.geojson.GeoJsonGeometry.Companion.COORDINATES_KEY
import kotlin.jvm.JvmInline
public sealed interface GeoJsonGeometry : GeoJson {
@@ -35,8 +34,8 @@ internal fun JsonElement.toGmc() = jsonArray.run {
}
internal fun Gmc.toJsonArray(): JsonArray = buildJsonArray {
add(longitude.degrees)
add(latitude.degrees)
add(longitude.toDegrees().value)
add(latitude.toDegrees().value)
elevation?.let {
add(it.meters)
}

View File

@@ -1,7 +1,7 @@
package center.sciprog.maps.geojson
package space.kscience.maps.geojson
import center.sciprog.attributes.SerializableAttribute
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.serializer
import space.kscience.attributes.serialization.SerializableAttribute
public object GeoJsonPropertiesAttribute : SerializableAttribute<JsonObject>("properties", serializer())

View File

@@ -1,4 +1,4 @@
package center.sciprog.maps.geojson
package space.kscience.maps.geojson
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor

View File

@@ -1,18 +1,17 @@
package center.sciprog.maps.geojson
package space.kscience.maps.geojson
import androidx.compose.ui.graphics.Color
import center.sciprog.attributes.NameAttribute
import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.features.*
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.features.*
/**
* Add a single Json geometry to a feature builder
*/
public fun FeatureGroup<Gmc>.geoJsonGeometry(
public fun FeatureBuilder<Gmc>.geoJsonGeometry(
geometry: GeoJsonGeometry,
id: String? = null,
): FeatureRef<Gmc, Feature<Gmc>> = when (geometry) {
@@ -50,11 +49,11 @@ public fun FeatureGroup<Gmc>.geoJsonGeometry(
}
}
public fun FeatureGroup<Gmc>.geoJsonFeature(
public fun FeatureBuilder<Gmc>.geoJsonFeature(
geoJson: GeoJsonFeature,
id: String? = null,
): FeatureRef<Gmc, Feature<Gmc>> {
val geometry = geoJson.geometry ?: return group {}
val geometry = geoJson.geometry ?: return group(null) {}
val idOverride = id ?: geoJson.getProperty("id")?.jsonPrimitive?.contentOrNull
return geoJsonGeometry(geometry, idOverride).modifyAttributes {
@@ -72,7 +71,7 @@ public fun FeatureGroup<Gmc>.geoJsonFeature(
}
}
public fun FeatureGroup<Gmc>.geoJson(
public fun FeatureBuilder<Gmc>.geoJson(
geoJson: GeoJson,
id: String? = null,
): FeatureRef<Gmc, Feature<Gmc>> = when (geoJson) {

View File

@@ -1,17 +1,17 @@
package center.sciprog.maps.geojson
package space.kscience.maps.geojson
import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.features.Feature
import center.sciprog.maps.features.FeatureGroup
import center.sciprog.maps.features.FeatureRef
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.features.Feature
import space.kscience.maps.features.FeatureBuilder
import space.kscience.maps.features.FeatureRef
import java.net.URL
/**
* Add geojson features from url
*/
public fun FeatureGroup<Gmc>.geoJson(
public fun FeatureBuilder<Gmc>.geoJson(
geoJsonUrl: URL,
id: String? = null,
): FeatureRef<Gmc, Feature<Gmc>> {

Some files were not shown because too many files have changed in this diff Show More