Repository: p-lr/MapCompose
Branch: master
Commit: 969062913203
Files: 163
Total size: 653.9 KB
Directory structure:
gitextract_n5gon859/
├── .gitignore
├── LICENSE
├── Readme.md
├── build.gradle
├── demo/
│ ├── .gitignore
│ ├── build.gradle
│ ├── proguard-rules.pro
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── assets/
│ │ │ └── tracks/
│ │ │ ├── track1.txt
│ │ │ ├── track2.txt
│ │ │ └── track3.txt
│ │ ├── java/
│ │ │ └── ovh/
│ │ │ └── plrapps/
│ │ │ └── mapcompose/
│ │ │ └── demo/
│ │ │ ├── MainActivity.kt
│ │ │ ├── providers/
│ │ │ │ └── TileStreamProviderFactory.kt
│ │ │ ├── ui/
│ │ │ │ ├── MapComposeDemoApp.kt
│ │ │ │ ├── NavGraph.kt
│ │ │ │ ├── screens/
│ │ │ │ │ ├── AddingMarkerDemo.kt
│ │ │ │ │ ├── AnimationDemo.kt
│ │ │ │ │ ├── CalloutDemo.kt
│ │ │ │ │ ├── CenteringOnMarkerDemo.kt
│ │ │ │ │ ├── CustomDraw.kt
│ │ │ │ │ ├── Home.kt
│ │ │ │ │ ├── HttpTilesDemo.kt
│ │ │ │ │ ├── InfiniteScrollDemo.kt
│ │ │ │ │ ├── LayersDemo.kt
│ │ │ │ │ ├── MarkersClusteringDemo.kt
│ │ │ │ │ ├── MarkersLazyLoadingDemo.kt
│ │ │ │ │ ├── OsmDemo.kt
│ │ │ │ │ ├── PathsDemo.kt
│ │ │ │ │ ├── RotationDemo.kt
│ │ │ │ │ ├── SimpleDemo.kt
│ │ │ │ │ └── VisibleAreaPaddingDemo.kt
│ │ │ │ ├── theme/
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ ├── Theme.kt
│ │ │ │ │ └── Type.kt
│ │ │ │ └── widgets/
│ │ │ │ ├── Callout.kt
│ │ │ │ └── Marker.kt
│ │ │ ├── utils/
│ │ │ │ ├── Metrics.kt
│ │ │ │ ├── Random.kt
│ │ │ │ └── WebMercator.kt
│ │ │ └── viewmodels/
│ │ │ ├── AddingMarkerVM.kt
│ │ │ ├── AnimationDemoVM.kt
│ │ │ ├── CalloutVM.kt
│ │ │ ├── CenteringOnMarkerVM.kt
│ │ │ ├── CustomDrawVM.kt
│ │ │ ├── HttpTilesVM.kt
│ │ │ ├── InfiniteScrollVM.kt
│ │ │ ├── LayersVM.kt
│ │ │ ├── MarkersClusteringVM.kt
│ │ │ ├── MarkersLazyLoadingVM.kt
│ │ │ ├── OsmVM.kt
│ │ │ ├── PathsVM.kt
│ │ │ ├── RotationVM.kt
│ │ │ ├── SimpleDemoVM.kt
│ │ │ └── VisibleAreaPaddingVM.kt
│ │ └── res/
│ │ ├── drawable/
│ │ │ ├── ic_launcher_background.xml
│ │ │ └── map_marker.xml
│ │ ├── drawable-v24/
│ │ │ └── ic_launcher_foreground.xml
│ │ ├── mipmap-anydpi-v26/
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ └── values/
│ │ ├── colors.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ └── test/
│ └── java/
│ └── ovh/
│ └── plrapps/
│ └── mapcompose/
│ └── demo/
│ └── utils/
│ └── WebMercatorTest.kt
├── doc/
│ └── mapcompose/
│ └── MapCompose.drawio
├── gradle/
│ ├── gradle-daemon-jvm.properties
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── mapcompose/
│ ├── build.gradle
│ ├── gradle.properties
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ └── java/
│ │ └── ovh/
│ │ └── plrapps/
│ │ └── mapcompose/
│ │ ├── api/
│ │ │ ├── Annotation.kt
│ │ │ ├── ApiDefaults.kt
│ │ │ ├── Common.kt
│ │ │ ├── DefaultCanvas.kt
│ │ │ ├── GesturesApi.kt
│ │ │ ├── LayerApi.kt
│ │ │ ├── LayoutApi.kt
│ │ │ ├── MarkerApi.kt
│ │ │ ├── Model.kt
│ │ │ ├── PathApi.kt
│ │ │ ├── RenderApi.kt
│ │ │ └── UtilsApi.kt
│ │ ├── core/
│ │ │ ├── ColorFilterProvider.kt
│ │ │ ├── Debounce.kt
│ │ │ ├── GestureConfiguration.kt
│ │ │ ├── Layer.kt
│ │ │ ├── Throttle.kt
│ │ │ ├── Tile.kt
│ │ │ ├── TileCollector.kt
│ │ │ ├── TileStreamProvider.kt
│ │ │ ├── Viewport.kt
│ │ │ └── VisibleTilesResolver.kt
│ │ ├── ui/
│ │ │ ├── MapUI.kt
│ │ │ ├── gestures/
│ │ │ │ ├── GestureDetector.kt
│ │ │ │ ├── TapGestureDetector.kt
│ │ │ │ └── model/
│ │ │ │ └── HitType.kt
│ │ │ ├── layout/
│ │ │ │ ├── MinimumScaleMode.kt
│ │ │ │ ├── Rendering.kt
│ │ │ │ └── ZoomPanRotate.kt
│ │ │ ├── markers/
│ │ │ │ ├── Clusterer.kt
│ │ │ │ ├── LazyLoader.kt
│ │ │ │ ├── MarkerComposer.kt
│ │ │ │ └── MarkerLayout.kt
│ │ │ ├── paths/
│ │ │ │ ├── PathComposer.kt
│ │ │ │ ├── RamerDouglaPeucker.kt
│ │ │ │ └── model/
│ │ │ │ ├── Cap.kt
│ │ │ │ └── PatternItem.kt
│ │ │ ├── state/
│ │ │ │ ├── MapState.kt
│ │ │ │ ├── PathState.kt
│ │ │ │ ├── TileCanvasState.kt
│ │ │ │ ├── ZoomPanRotateState.kt
│ │ │ │ └── markers/
│ │ │ │ ├── MarkerRenderState.kt
│ │ │ │ ├── MarkerState.kt
│ │ │ │ └── model/
│ │ │ │ ├── ClusterClickBehavior.kt
│ │ │ │ ├── MarkerData.kt
│ │ │ │ ├── MarkerType.kt
│ │ │ │ └── RenderingStrategy.kt
│ │ │ └── view/
│ │ │ └── TileCanvas.kt
│ │ └── utils/
│ │ ├── AnimUtils.kt
│ │ ├── ApiUtils.kt
│ │ ├── BoundingBoxUtils.kt
│ │ ├── Collections.kt
│ │ ├── Dp.kt
│ │ ├── Flow.kt
│ │ ├── Geometry.kt
│ │ ├── Point.kt
│ │ ├── RotationUtils.kt
│ │ └── VisibleAreaUtils.kt
│ └── test/
│ └── java/
│ └── ovh/
│ └── plrapps/
│ └── mapcompose/
│ ├── core/
│ │ ├── TileCollectorTest.kt
│ │ └── VisibleTilesResolverTest.kt
│ ├── state/
│ │ └── TileCanvasStateTest.kt
│ ├── ui/
│ │ └── paths/
│ │ └── PathComposerTest.kt
│ └── utils/
│ ├── GeometryTest.kt
│ └── VisibleAreaUtilsTest.kt
├── settings.gradle
└── testapp/
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src/
├── main/
│ ├── AndroidManifest.xml
│ ├── java/
│ │ └── ovh/
│ │ └── plrapps/
│ │ └── mapcompose/
│ │ └── testapp/
│ │ ├── MainActivity.kt
│ │ ├── core/
│ │ │ └── ui/
│ │ │ ├── MapComposeTestApp.kt
│ │ │ ├── nav/
│ │ │ │ └── NavDestinations.kt
│ │ │ └── theme/
│ │ │ ├── Color.kt
│ │ │ ├── Theme.kt
│ │ │ └── Type.kt
│ │ ├── features/
│ │ │ ├── clustering/
│ │ │ │ ├── MarkerClusteringUi.kt
│ │ │ │ └── MarkersClusteringViewModel.kt
│ │ │ ├── home/
│ │ │ │ └── Home.kt
│ │ │ └── layerswitch/
│ │ │ ├── LayerSwitchTest.kt
│ │ │ └── LayerSwitchViewModel.kt
│ │ └── utils/
│ │ └── Random.kt
│ └── res/
│ ├── drawable/
│ │ ├── ic_launcher_background.xml
│ │ └── map_marker.xml
│ ├── drawable-v24/
│ │ └── ic_launcher_foreground.xml
│ ├── mipmap-anydpi-v26/
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ └── values/
│ ├── colors.xml
│ ├── strings.xml
│ └── themes.xml
└── test/
└── java/
└── ovh/
└── plrapps/
└── mapcompose/
└── testapp/
└── ExampleUnitTest.kt
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build
/captures
.idea
.externalNativeBuild
publish-mavenCentral.sh
compile_and_install_demo_release.sh
/publishErrors.txt
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: Readme.md
================================================
[](https://central.sonatype.com/artifact/ovh.plrapps/mapcompose)
[](http://www.apache.org/licenses/LICENSE-2.0)
[](https://developer.android.com/jetpack/compose/bom/bom)
🎉 News:
- `3.1.0` now supports infinite scroll (#119).
- `3.0.0` is released. The library is now capable of handling much bigger maps, such as world-wide
OpenStreetMap at zoom level 17 with a maximum scale of 8. See [Migrate from 2.x.x](#migrate-from-2xx).
- Memory footprint has been dramatically reduced on Android 10 and above, by leveraging [Hardware Bitmaps](https://bumptech.github.io/glide/doc/hardwarebitmaps.html).
- MapCompose Multiplatform is officially released: https://github.com/p-lr/MapComposeMP \
Works on iOS, MacOS, Windows, Linux, and Android.
# MapCompose
MapCompose is a fast, memory efficient Jetpack compose library to display tiled maps with minimal effort.
It shows the visible part of a tiled map with support of markers and paths, and various gestures
(flinging, dragging, scaling, and rotating).
An example of setting up:
```kotlin
/* Inside your view-model */
val tileStreamProvider = TileStreamProvider { row, col, zoomLvl ->
FileInputStream(File("path/{$zoomLvl}/{$row}/{$col}.jpg")) // or it can be a remote HTTP fetch
}
val state = MapState(4, 4096, 4096).apply {
addLayer(tileStreamProvider)
enableRotation()
}
/* Inside a composable */
@Composable
fun MapContainer(
modifier: Modifier = Modifier, viewModel: YourViewModel
) {
MapUI(modifier, state = viewModel.state)
}
```
This project holds the source code of this library, plus a demo app - which is useful to get started.
To test the demo, just clone the repo and launch the demo app from Android Studio.
## Clustering
Marker clustering regroups markers of close proximity into clusters. The video below shows how it works.
https://github.com/p-lr/MapCompose/assets/15638794/de48cb1b-396b-44d3-b47a-e3d719e8f38a
The sample below shows the relevant part of the code. We can still add regular markers (not managed by a clusterer), such as the red marker in the video.
See the [full code](demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/MarkersClusteringVM.kt).
```kotlin
/* Add clusterer */
state.addClusterer("default") { ids ->
{ Cluster(size = ids.size) }
}
/* Add marker managed by the clusterer */
state.addMarker(
id = "marker",
x = 0.2,
y = 0.3,
renderingStrategy = RenderingStrategy.Clustering("default"),
) {
Marker()
}
```
There's an example in the demo app.
## Installation
Add this to your module's build.gradle
```groovy
implementation 'ovh.plrapps:mapcompose:3.2.7'
```
Starting with v.2.4.1, the library is using the
[compose BOM](https://developer.android.com/jetpack/compose/bom/bom). The version of the BOM is
specified in the release notes. The demo app shows an example of how to use it.
## Basics
MapCompose is optimized to display maps that have several levels, like this:
Each next level is twice bigger than the former, and provides more details. Overall, this looks like
a pyramid. Another common name is "deep-zoom" map.
This library comes with a demo app featuring various use-cases such as using markers, paths,
map rotation, etc. All examples use the same map stored in the assets, which is a great example of
deep-zoom map.
MapCompose can also be used with single level maps.
### Usage
In a typical application, you create a `MapState` instance inside a `ViewModel` (or whatever
component which survives device rotation). Your `MapState` should then be passed to the `MapUI`
composable. The code sample at the top of this readme shows an example. Then, whenever you need to
update the map (add a marker, a path, change the scale, etc.), you invoke APIs on your `MapState`
instance. As its name suggests, `MapState` also _owns_ the state. Therefore, composables will always
render consistently - even after a device rotation.
All public APIs are located under the [api](mapcompose/src/main/java/ovh/plrapps/mapcompose/api)
package. The following sections provide details on the `MapState` class, and give examples of how to
add markers, callouts, and paths.
All apis should be invoked from the main thread.
### MapState
The `MapState` class expects three parameters for its construction:
* `levelCount`: The number of levels of the map,
* `fullWidth`: The width of the map at scale 1.0, which is the width of last level,
* `fullHeight`: The height of the map at scale 1.0, which is the height of last level
### Layers
MapCompose supports layers - e.g it's possible to add several tile pyramids. Each level is made of
the superposition of tiles from all pyramids at the given level. For example, at the second level
(starting from the lowest scale), tiles would look like the image below when three layers are added.
Your implementation of the `TileStreamProvider` interface (see below) is what defines a tile
pyramid. It provides `InputStream`s of image files (png, jpg). MapCompose will request tiles using
the convention that the origin is at the top-left corner. For example, the tile requested with
`row` = 0, and `col = 0` will be positioned at the top-left corner.
```kotlin
fun interface TileStreamProvider {
suspend fun getTileStream(row: Int, col: Int, zoomLvl: Int): InputStream?
}
```
Depending on your configuration, your `TileStreamProvider` implementation might fetch local files,
as well as performing remote HTTP requests - it's up to you. You don't have to worry about threading,
MapCompose takes care of that (the main thread isn't blocked by `getTileStream` calls). However, in
case of HTTP requests, it's advised to create a `MapState` with a higher than default `workerCount`.
That optional parameter defines the size of the dedicated thread pool for fetching tiles, and defaults
to the number of cores minus one. Typically, you would want to set `workerCount` to 16 when performing
HTTP requests. Otherwise, you can safely leave it to its default.
To add a layer, use the `addLayer` on your `MapState` instance. There are others APIs for reordering,
removing, setting alpha - all dynamically.
### Markers
To add a marker, use the [addMarker](https://github.com/p-lr/MapCompose/blob/982caf29ab5e86b58c56812735f60bfe405638ea/mapcompose/src/main/java/ovh/plrapps/mapcompose/api/MarkerApi.kt#L30)
API, like so:
```kotlin
/* Add a marker at the center of the map */
mapState.addMarker("id", x = 0.5, y = 0.5) {
Icon(
painter = painterResource(id = R.drawable.map_marker),
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = Color(0xCC2196F3)
)
}
```
A marker is a composable that you supply (in the example above, it's an `Icon`). It can be
whatever composable you like. A marker does not scale, but it's position updates as the map scales,
so it's always attached to the original position. A marker has an anchor point defined - the point
which is fixed relatively to the map. This anchor point is defined using relative offsets, which are
applied to the width and height of the marker. For example, to have a marker centered horizontally
and aligned at the bottom edge (like a typical map pin would do), you'd pass -0.5f and -1.0f as
relative offsets (left position is offset by half the width, and top is offset by the full height).
If necessary, an absolute offset expressed in pixels can be applied, in addition to the
relative offset.
Markers can be moved, removed, and be draggable. See the following APIs: [moveMarker](https://github.com/p-lr/MapCompose/blob/2fbf0967290ffe01d63a6c65a3022568ef48b9dd/mapcompose/src/main/java/ovh/plrapps/mapcompose/api/MarkerApi.kt#L72),
[removeMarker](https://github.com/p-lr/MapCompose/blob/2fbf0967290ffe01d63a6c65a3022568ef48b9dd/mapcompose/src/main/java/ovh/plrapps/mapcompose/api/MarkerApi.kt#L61),
[enableMarkerDrag](https://github.com/p-lr/MapCompose/blob/2fbf0967290ffe01d63a6c65a3022568ef48b9dd/mapcompose/src/main/java/ovh/plrapps/mapcompose/api/MarkerApi.kt#L89).
### Callouts
Callouts are typically message popups which are, like markers, attached to a specific position.
However, they automatically dismiss on touch down. This default behavior can be changed.
To add a callout, use [addCallout](https://github.com/p-lr/MapCompose/blob/2fbf0967290ffe01d63a6c65a3022568ef48b9dd/mapcompose/src/main/java/ovh/plrapps/mapcompose/api/MarkerApi.kt#L220).
Callouts can be programmatically removed (if automatic dismiss was disabled).
### Paths
To add a path, use the `addPath` api:
```kotlin
mapState.addPath("pathId", color = Color(0xFF448AFF)) {
addPoints(points)
}
```
The demo app shows a complete example.
## Animate state change
It's pretty common to programmatically animate the scroll and/or the scale, or even the rotation of
the map.
*scroll and/or scale animation*
When animating the scale, we generally do so while maintaining the center of the screen at
a specific position. Likewise, when animating the scroll position, we can do so with or without
animating the scale altogether, using [scrollTo](https://github.com/p-lr/MapCompose/blob/08c0f68f654c1ce27a295f3fb6c25e9cf4274de9/mapcompose/src/main/java/ovh/plrapps/mapcompose/api/LayoutApi.kt#L188)
and [snapScrollTo](https://github.com/p-lr/MapCompose/blob/08c0f68f654c1ce27a295f3fb6c25e9cf4274de9/mapcompose/src/main/java/ovh/plrapps/mapcompose/api/LayoutApi.kt#L161).
*rotation animation*
For animating the rotation while keeping the current scale and scroll, use the
[rotateTo](https://github.com/p-lr/MapCompose/blob/08c0f68f654c1ce27a295f3fb6c25e9cf4274de9/mapcompose/src/main/java/ovh/plrapps/mapcompose/api/LayoutApi.kt#L149) API.
Both `scrollTo` and `rotateTo` are suspending functions. Therefore, you know exactly when
an animation finishes, and you can easily chain animations inside a coroutine.
```kotlin
// Inside a ViewModel
viewModelScope.launch {
mapState.scrollTo(0.8, 0.8, destScale = 2f)
mapState.rotateTo(180f, TweenSpec(2000, easing = FastOutSlowInEasing))
}
```
For a detailed example, see the "AnimationDemo".
## Design changes and differences with MapView
* In MapView, you had to define bounds before you could add markers. There's no such concept
in MapCompose anymore. Now, coordinates are normalized. For example, (x=0.5, y=0.5) is a point located at
the center of the map. Normalized coordinates are easier to reason about, and application code can
still translate this coordinate system to a custom one.
* In MapView, you had to build a configuration and use that configuration to create a `MapView`
instance. There's no such thing in MapCompose. Now, you create a `MapState` object with required
parameters.
* A lot of things which couldn't change after MapView configuration can now be changed dynamically
in MapCompose. For example, the `zIndex` of a marker, or the minimum scale mode can be changed at
runtime.
## Migrate from `2.x.x`
* All apis taking the scale as parameter now require `Double` values instead of `Float`.
* A few low-level apis taking the scroll in pixels now require `Double` values instead of `Float`.
* The `addMarker` api now longer has the parameter `clipShape`.
## Contributors
Marcin (@Nohus) has contributed and fixed some issues. He also thoroughly tested the new layers
feature – which made `MapCompose` better.
================================================
FILE: build.gradle
================================================
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext {
kotlin_version = "2.2.21"
coroutine_version = '1.10.2'
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:9.2.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
================================================
FILE: demo/.gitignore
================================================
/build
================================================
FILE: demo/build.gradle
================================================
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id 'com.android.application'
id 'kotlin-android'
id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version"
id 'kotlin-parcelize'
}
android {
compileSdk = 36
defaultConfig {
applicationId "ovh.plrapps.mapcompose.demo"
minSdk = 23
targetSdk = 36
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
namespace = 'ovh.plrapps.mapcompose.demo'
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll([
'-Xopt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi',
'-Xopt-in=androidx.compose.foundation.ExperimentalFoundationApi',
])
jvmTarget = JvmTarget.JVM_17
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.18.0'
implementation 'androidx.appcompat:appcompat:1.7.1'
// Compose - See https://developer.android.com/jetpack/compose/setup#bom-version-mapping
implementation platform('androidx.compose:compose-bom:2026.04.01')
implementation "androidx.compose.ui:ui"
implementation "androidx.compose.material:material"
implementation "androidx.compose.material3:material3"
implementation "androidx.compose.ui:ui-tooling-preview"
debugImplementation "androidx.compose.ui:ui-tooling"
implementation 'androidx.navigation:navigation-compose:2.9.8'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.10.0'
implementation project(':mapcompose')
testImplementation 'junit:junit:4.13.2'
}
================================================
FILE: demo/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
================================================
FILE: demo/src/main/AndroidManifest.xml
================================================
================================================
FILE: demo/src/main/assets/tracks/track1.txt
================================================
0.6044638908531056,0.23086354170969253
0.6044638908531056,0.230781790986988
0.6075361613235069,0.22873802292007173
0.6100394928179091,0.23168104893644745
0.6137944900595099,0.23225330399520489
0.6170374422227098,0.2303730373736396
0.6207355455667128,0.230945292432339
0.624376755013113,0.23135404604574544
0.628074858357116,0.23168104893644745
0.6307488715443168,0.23429707206217923
0.6302368264659158,0.23797585458272147
0.6306919776467139,0.24165463710326368
0.6304644020563174,0.24533341962380592
0.6329677335507197,0.24811294419488875
0.6345038687859178,0.25154647454737544
0.6371778819731186,0.2539989962276982
0.640762197521921,0.25514350634521316
0.644460300865924,0.2553887585132687
0.6474756774387223,0.25326323972364784
0.6511168868851224,0.2524457324968349
0.654473626843528,0.250974219488618
0.6572614278259298,0.24852169780823716
0.6569200644403274,0.2522004803287794
0.6597078654227291,0.24974795864845659
0.66238187860993,0.25236398177413033
0.6607319555795311,0.2557157614039706
0.6585699874707263,0.25874053814310893
0.6622680908147293,0.2582500338069979
0.665567936875532,0.25653326863078363
0.6686971012435311,0.25457125128645564
0.6675592232915333,0.25816828308435147
0.6662506636467317,0.2617653148821892
0.6641455894355297,0.2647900916213275
0.665567936875532,0.2682236219738142
0.66602308805633,0.2718206537716519
0.6679574805747335,0.2750089319561412
0.6685833134483302,0.2786877144766834
0.6696642975027303,0.2822847462745211
0.6711435388403354,0.28563652590436134
0.6748416421843334,0.28604527951770975
0.6752967933651366,0.28972406203825196
0.6733624008467333,0.2929123402227413
0.671314220533134,0.2959371169618215
0.6712573266355312,0.2996976502050683
0.6736468703347327,0.3025589254987975
0.6751830055699358,0.30591070512863777
0.6756950506483369,0.3096712383718265
0.6719969473043338,0.30983473981717746
0.6691522524243343,0.31220551077485376
0.6700625547859305,0.31580254257274953
0.672964143563533,0.3181733135304258
0.6755812628531361,0.32070758593345317
0.6778001248595338,0.32373236267253336
0.6796207295827364,0.3270023915797272
0.6781983821427341,0.3304359219322139
0.6746709604915347,0.3292096610920525
0.6726796740755335,0.33231618855383727
0.6712004327379333,0.335749718906324
0.6747847482867356,0.33673072757842987
0.6725658862803328,0.3396737535948636
0.6764346713171344,0.3397555043175682
0.6800189868659366,0.3412270173257851
0.6838308780051404,0.3409817651577296
0.6817258037939384,0.34400654189686786
0.678767321118738,0.3461320606864887
0.6774587614739365,0.34964734176167994
0.6754674750579353,0.3527538692234647
0.6733624008467333,0.35577864596254494
0.6696642975027303,0.3556151445171939
0.6663075575443296,0.35398013006362605
0.6626663480979295,0.3545523851223836
0.6598785471155277,0.35700490680276437
0.6576027912115271,0.3598661820964936
0.6592527142419259,0.3632179617263339
0.658683775265927,0.3668967442468762
0.6565787010547249,0.3699215209859564
0.655554610897928,0.37351855278379403
0.6580010484947273,0.37629807735487686
0.6572614278259298,0.37989510915277264
0.6594233959347295,0.38300163661455744
0.6608457433747269,0.3863534162443396
0.6636335443571286,0.3888876886473669
0.6673316477011316,0.3892146915381269
0.6706314937619344,0.3908497059916948
0.6719969473043338,0.3942832363441815
0.6756950506483369,0.3942832363441815
0.6789948967091346,0.3959182507977494
0.6813844404083361,0.39877952609147865
0.685082543752339,0.3984525232007767
0.6886668593011412,0.39755326525131723
0.6923649626451444,0.39722626236061526
0.6948114002419437,0.4000875376543445
0.6977129890195461,0.4023765578893744
0.7014110923635442,0.40286706222542734
0.7051091957075472,0.4029488129481319
0.7060194980691484,0.39935178115023606
0.7067022248403481,0.39567299862969385
0.7091486624371475,0.39297522478131564
0.7122778268051516,0.39093145671434126
0.7137570681427517,0.38757967708455904
0.7152932033779499,0.38422789745471875
0.718934412824355,0.38406439600936776
0.7225756222707551,0.3834921409506103
0.7259892561267536,0.38193887721968894
0.7286632693139545,0.3794046048166616
0.7302562984467555,0.37605282518687944
0.7325889482483592,0.37319154989309206
0.7357750065139611,0.3712295325488223
0.7380507624179619,0.36836825725509303
0.7416919718643619,0.3691857644818479
0.7451624996179633,0.3705755267673603
0.7484054517811631,0.37221054122098624
0.7518190856371667,0.373682054229145
0.7555171889811698,0.37417255856525605
0.7592152923251677,0.37458131217866253
0.7625720322835734,0.3730280484477411
0.7644495309043738,0.3698397702632518
0.7672373318867706,0.36738724858292904
0.7697406633811728,0.36460772401184627
0.7730974033395734,0.3629727095582784
0.7764541432979791,0.36150119655006147
0.7798677771539776,0.36002968354184456
0.7827124720339772,0.3577406633068728
0.7851589096307816,0.35487938801308544
0.783907243883578,0.35144585766059877
0.7822004269555812,0.34817582875346303
0.7803798222323787,0.3449057998463273
0.7783316419187796,0.341881023107189
0.7762265677075776,0.33877449564540424
0.7741783873939734,0.335749718906324
0.7717319497971741,0.3330519450579457
0.7695130877907762,0.3300271683188074
0.7670666501939719,0.32724764374772464
0.764392637006771,0.3247133713446973
0.7624582444883726,0.3215250931602661
0.7598411251987696,0.3189090700345343
0.7562568096499673,0.3178463106397239
0.7529000696915666,0.31629304690880244
0.7503398442995666,0.31367702378307066
0.747438255521964,0.3114697542707453
0.744252197256362,0.30958948764918
0.7420902291475622,0.30656471091004167
0.7419764413523613,0.3028859283894995
0.7447642423347631,0.30043340670917673
0.7473813616243662,0.2978173835834449
0.7497140114259647,0.2948743575670111
0.7525587063059643,0.29258533733203934
0.7551758255955674,0.289887563483603
0.7576222631923667,0.28710803891257825
0.7600118068915682,0.28424676361879087
0.7617755177171729,0.2809767347116552
0.7649046820851719,0.2789329666446808
0.7687734671219736,0.2781154594179259
0.7686027854291749,0.27427317545203267
0.769114830507576,0.27059439293149046
0.7724715704659767,0.26912287992327355
0.7756576287315786,0.26658860752024627
0.777592021249977,0.2633185786131106
0.7804936100275794,0.26094780765543424
0.7827693659315802,0.25767777874824044
0.7836227743955786,0.2539172455050518
0.7827124720339772,0.24999321081645404
0.7837934560883772,0.24623267757326536
0.7843055011667782,0.24255389505272312
0.7848744401427822,0.23879336180953445
0.7841917133715774,0.23503282856628768
0.7835089866003777,0.23127229532309904
0.7831676232147804,0.2275935128025568
0.7868657265587834,0.22685775629844837
0.786524363173181,0.22317897377790613
0.7868657265587834,0.21941844053465936
0.7886294373843831,0.21614841162752363
0.7906776176979822,0.21304188416573885
0.7914172383667848,0.20936310164519664
0.793408524782786,0.2062565741834119
0.7952860234035866,0.20306829599898066
0.7980738243859831,0.20053402359595338
0.8004633680851847,0.19767274830216605
0.8024546545011859,0.19448447011773484
0.8055269249715871,0.1924407020507605
0.8079733625683915,0.18933417458897572
0.8110456330387926,0.18729040652200138
0.8143454790995904,0.1856553920684335
0.8181004763411963,0.18557364134572896
0.8216278979923956,0.18688165290859488
0.8252122135411978,0.18573714279113804
0.8261225159027992,0.18214011099324226
0.8293085741683961,0.18025984437161888
0.8330066775123991,0.17976934003556594
0.8363065235732018,0.18148610521183833
0.837785764910802,0.1848378848416205
0.8392650062484022,0.18827141519416532
0.8415407621524029,0.18532838917773156
0.8419390194356031,0.18156785593448477
0.8430200034900031,0.17797082413664708
0.8456371227796061,0.18066859798502535
0.8466612129364032,0.18426562978292113
0.8483111359668071,0.18091385015308087
0.8485387115572087,0.17723506763253866
0.8482542420692042,0.17347453438934998
0.8463767434484036,0.17028625620486068
0.8471732580148043,0.166689224407023
0.8480835603764055,0.16301044188648076
0.8480266664788076,0.15933165936593852
0.8483680298644051,0.15557112612269178
0.8453526532916068,0.15344560733307094
0.848880074942806,0.15213759577020505
0.8485387115572087,0.1484588132496628
0.848424923762008,0.14469828000647417
0.8492783322260062,0.1411012482085784
0.8455802288820032,0.14101949748593193
0.8419959133332061,0.14191875543539134
0.8385253855796047,0.1431450162755527
0.8357944784948007,0.140528993149879
0.832494632433998,0.1388122279736066
0.8287965290900001,0.1386487265282556
0.827146606059596,0.1420005061580959
0.8247001684627968,0.14469828000647417
0.8210589590163967,0.14551578723322905
0.8179866885459955,0.1475595553002034
0.8142316913043947,0.14764130602290793
0.8105904818579894,0.14829531180431182
0.8068923785139915,0.1487858161403648
0.8033649568627871,0.15001207698058425
0.7996668535187842,0.1502573291485817
0.7996099596211863,0.146496795905393
0.7962532196627857,0.14813181035896086
0.798187612181184,0.1449435321744716
0.7949446600179841,0.14322676699825726
0.7943757210419852,0.1394662337550105
0.7913603444691819,0.14167350326733585
0.7888001190771817,0.1443712771157722
0.7852158035283795,0.14518878434252708
0.7814608062867786,0.14543403651058262
0.7785023236115782,0.14764130602290793
0.7748611141651781,0.1484588132496628
0.771788843694777,0.15066608276198817
0.7682614220435726,0.15172884215679858
0.7646202125971725,0.15270985082896252
0.7609790031507725,0.1535273580557755
0.7576791570899696,0.15508062178663884
0.758589459451571,0.15148358998880113
0.7557447645715664,0.1539361116691239
0.7525587063059643,0.15573462756804274
0.7499415870163664,0.1584324014164791
0.7485761334739669,0.1618659317689658
0.7472675738291653,0.16538121284415708
0.7465848470579657,0.16897824464199476
0.7449349240275618,0.1722482735491305
0.7412937145811617,0.17322928222129444
0.7376525051347615,0.1739650387254029
0.7339544017907585,0.17445554306145583
0.7312803886035576,0.17698981546448314
0.7288339510067582,0.17976934003556594
0.7254772110483576,0.18148610521183833
0.7223480466803535,0.18352987327881268
0.7193326701075553,0.18581889351378447
0.7162035057395512,0.1877809108580543
0.7137570681427517,0.19072393687448808
0.7115382061363489,0.19366696289092186
0.708352147870747,0.19562898023519168
0.7055074529907474,0.19808150191557253
0.7022076069299447,0.19979826709184492
0.6994766998451458,0.19726399468881764
0.6974285195315417,0.19423921794967933
0.6937873100851415,0.19366696289092186
0.6913408724883422,0.19080568759719263
0.6884392837107397,0.19317645855486892
0.6851394376499369,0.1948114730084368
0.6822947427699373,0.1924407020507605
0.6786535333235372,0.1916231948239475
0.6749554299795343,0.19178669626929848
0.6715417961235357,0.1933399600002199
0.6692091463219321,0.19620123529394914
0.6671040721107301,0.19930776275573392
0.6644300589235291,0.2018420351588193
0.6634059687667322,0.20543906695665698
0.6613008945555301,0.20846384369573723
0.6582286240851289,0.2105893624854161
0.6548718841267283,0.21214262621627944
0.6512306746803231,0.2127148812750369
0.6475325713363251,0.21345063777914536
0.6441189374803216,0.2148404000646577
0.6405346219315193,0.21598491018217264
0.6371778819731186,0.21761992463574054
0.6337642481171152,0.21917318836666197
0.6304644020563174,0.22088995354287627
0.6272783437907155,0.2229337216098506
0.6244336489107158,0.22530449256752694
0.6215889540307112,0.22783876497055422
0.6189149408435103,0.2303730373736396
0.6157857764755112,0.23110879387774805
================================================
FILE: demo/src/main/assets/tracks/track2.txt
================================================
0.5442701471922805,0.22415998245007007
0.5441563593970797,0.22415998245007007
0.5443839349874813,0.22407823172736555
0.5443839349874813,0.223996481004661
0.5442132532946826,0.22391473028201458
0.5443839349874813,0.22407823172736555
0.5443270410898784,0.22424173317271653
0.5441563593970797,0.22415998245007007
0.5443270410898784,0.2244052346180675
0.5441563593970797,0.22456873606341848
0.5442132532946826,0.22456873606341848
0.5442132532946826,0.22448698534077202
0.544042571601879,0.2243234838954211
0.543530526523478,0.2244052346180675
0.5431322692402777,0.2243234838954211
0.542961587547479,0.22407823172736555
0.5429046936498813,0.223996481004661
0.5420512851858778,0.2226884694418532
0.5419943912882799,0.22260671871914864
0.5415392401074818,0.2222797158284467
0.5409703011314778,0.22170746076968925
0.5409134072338799,0.2216257100469847
0.5401737865650773,0.2212169564336363
0.5398893170770779,0.2212169564336363
0.5400599987698765,0.22129870715628275
0.5400599987698765,0.2213804578789873
0.5401737865650773,0.2212169564336363
0.5399462109746808,0.22088995354287627
0.5396617414866762,0.22064470137487885
0.539263484203481,0.2205629506521743
0.5392065903058781,0.22048119992952786
0.5388083330226778,0.22015419703876782
0.5378980306610767,0.2199089448707704
0.5377842428658759,0.21982719414806587
0.5364756832210794,0.2193366898120129
0.5364187893234764,0.2193366898120129
0.5352809113714786,0.21884618547595996
0.5352809113714786,0.21892793619860643
0.5351102296786748,0.21876443475325547
0.535053335781077,0.21860093330790448
0.5349395479858762,0.21770167535844506
0.5349395479858762,0.21761992463574054
0.5347688662930775,0.21721117102239212
0.534541290702676,0.21688416813163208
0.5342568212146765,0.2163119130728746
0.5342568212146765,0.21623016235022818
0.5336878822386776,0.21492215078736224
0.5336309883410747,0.2148404000646577
0.5333465188530752,0.21451339717395576
0.5328913676722771,0.21353238850184988
0.5328344737746742,0.21345063777914536
0.5327775798770763,0.21320538561108984
0.5324931103890769,0.2128783827203879
0.5324931103890769,0.21263313055239044
0.5323224286962782,0.21255137982968594
0.5321517470034745,0.21214262621627944
0.5320948531058766,0.212060875493633
0.5317534897202741,0.21157037115752197
0.5313552324370739,0.21116161754417356
0.5311845507442753,0.21132511898952455
0.5311845507442753,0.21132511898952455
0.530843187358673,0.21132511898952455
0.5307293995634772,0.211406869712171
0.5303880361778749,0.212060875493633
0.530331142280277,0.21214262621627944
0.5301035666898753,0.2128783827203879
0.5296484155090723,0.2133688870564989
0.5295915216114744,0.21345063777914536
0.5293070521234748,0.21385939139255183
0.5290225826354754,0.21418639428325378
0.5288519009426768,0.2144316464513093
0.5287950070450739,0.2145951478966603
0.528738113147476,0.21467689861930675
0.5289087948402746,0.21492215078736224
0.5288519009426768,0.21508565223271323
0.5289087948402746,0.21533090440076874
0.5286243253522752,0.21582140873682168
0.5286243253522752,0.21598491018217264
0.5286243253522752,0.21623016235022818
0.5285105375570743,0.2167206666862811
0.5281122802738741,0.21721117102239212
0.5280553863762762,0.21729292174503856
0.5279415985810755,0.21761992463574054
0.5275433412978752,0.21851918258519992
0.5274864474002723,0.21860093330790448
0.5268606145266755,0.21950019125736392
0.5267468267314747,0.21982719414806587
0.5266899328338718,0.2199089448707704
0.526576145038671,0.22015419703876782
0.5262347816530737,0.2205629506521743
0.526064099960275,0.2209717042655808
0.5259503121650742,0.2210534549882853
0.526007206062672,0.2212169564336363
0.5259503121650742,0.2212169564336363
0.5257796304722705,0.22170746076968925
0.5254951609842711,0.2218709622150402
0.5251537975986738,0.22195271293774474
0.5249262220082722,0.22219796510574216
0.5248693281106743,0.22219796510574216
0.5244710708274741,0.22260671871914864
0.5243003891346704,0.22260671871914864
0.52390213185147,0.2229337216098506
0.5237883440562744,0.2229337216098506
0.5227073600018693,0.2237512288366636
0.5225935722066736,0.22383297955931003
0.5221953149234734,0.223996481004661
0.5220246332306696,0.223996481004661
0.5212850125618721,0.22424173317271653
0.5211712247666712,0.22424173317271653
0.5208867552786718,0.2243234838954211
0.520204028507472,0.22407823172736555
0.5198057712242719,0.22415998245007007
0.519748877326669,0.22415998245007007
0.5193506200434688,0.22424173317271653
0.5188954688626706,0.22407823172736555
0.5184403176818725,0.22407823172736555
0.5183834237842696,0.22407823172736555
0.5178144848082706,0.22424173317271653
0.5170179702418701,0.22407823172736555
0.5169041824466694,0.22415998245007007
0.5166766068562677,0.2243234838954211
0.5154249411090691,0.2243234838954211
0.5153111533138683,0.22424173317271653
0.5142301692594684,0.2244052346180675
0.513945699771469,0.2243234838954211
0.5138319119762681,0.2243234838954211
0.5131491852050685,0.22407823172736555
0.5125233523314666,0.22407823172736555
0.5124664584338688,0.22407823172736555
0.5122388828434672,0.22415998245007007
0.5114423682770667,0.22407823172736555
0.5112147926866651,0.22415998245007007
0.5111010048914694,0.22424173317271653
0.5101907025298681,0.2243234838954211
0.5098493391442657,0.2244052346180675
0.5097924452466678,0.22448698534077202
0.5090528245778652,0.22424173317271653
0.5085407794994641,0.2244052346180675
0.5084838856018662,0.2244052346180675
0.5074597954450643,0.22456873606341848
0.5074029015474664,0.22465048678612304
0.5073460076498635,0.22473223750876944
0.5071753259570648,0.224977489676825
0.507061538161864,0.22489573895417853
0.5067770686738645,0.22448698534077202
0.5063219174930664,0.2244052346180675
0.5062081296978657,0.2244052346180675
0.5047857822578633,0.22415998245007007
0.5047288883602654,0.22407823172736555
0.5043306310770651,0.22391473028201458
0.5039323737938649,0.2244052346180675
0.5039323737938649,0.22415998245007007
0.5041030554866636,0.22407823172736555
0.5042168432818643,0.22383297955931003
0.5041599493842664,0.22391473028201458
0.5042168432818643,0.22383297955931003
0.5042737371794622,0.22407823172736555
0.5042168432818643,0.22415998245007007
0.504444418872266,0.22415998245007007
0.504387524974663,0.2243234838954211
0.5042168432818643,0.22424173317271653
0.5037616921010662,0.22424173317271653
0.5037047982034633,0.2243234838954211
0.5033065409202631,0.22456873606341848
0.5028513897394649,0.22522274184488048
0.502794495841862,0.22530449256752694
0.5019410873778637,0.22587674762628437
0.5018272995826629,0.22620375051698632
0.5018272995826629,0.22636725196233734
0.5016566178898643,0.22653075340774637
0.5013152545042618,0.22669425485309738
0.5010307850162624,0.22702125774379933
0.5008601033234638,0.22718475918915032
0.5008601033234638,0.2274300113572058
0.5008032094258609,0.22751176207985227
0.4997222253714609,0.22816576786131426
0.4996084375762601,0.22824751858396072
0.4993808619858635,0.22832926930666525
0.4992101802930598,0.22832926930666525
0.4989257108050604,0.22800226641596327
0.49829987793146363,0.22816576786131426
0.49824298403386064,0.22824751858396072
0.4979585145458612,0.22832926930666525
0.4979016206482633,0.22824751858396072
0.49773093895545967,0.22783876497055422
0.4976171511602639,0.22775701424790776
0.49744646946746013,0.2275935128025568
0.4972188938770637,0.22767526352520323
0.4971619999794607,0.22767526352520323
0.4968775304914613,0.22783876497055422
0.4965361671058589,0.22792051569325877
0.4961948037202616,0.22816576786131426
0.4957396525394584,0.22824751858396072
0.49562586474426273,0.22832926930666525
0.4942604112018582,0.2284927707520162
0.4942035173042604,0.22841102002931168
0.4936914722258593,0.22832926930666525
0.49289495765945884,0.2284927707520162
0.4927811698642581,0.2284927707520162
0.4913019285266578,0.22832926930666525
0.49124503462905994,0.22832926930666525
0.49056230785786026,0.22857452147466267
0.4900502627794592,0.22881977364271816
0.4899933688818563,0.22890152436542271
0.48891238482745636,0.22988253303752856
0.48885549092985847,0.22988253303752856
0.4886279153394568,0.2301277852055841
0.48817276415865873,0.23127229532309904
0.48811587026105585,0.23127229532309904
0.4878882946706593,0.23192630110450294
0.4878882946706593,0.23225330399520489
0.4877176129778556,0.2326620576086114
0.4876038251826548,0.23274380833131592
0.4872624617970575,0.2330708112220179
0.4872055678994596,0.23339781411271984
0.48669352282105854,0.23380656772612632
0.48663662892345566,0.23380656772612632
0.4864090533330591,0.23397006917147728
0.4856125387666586,0.23413357061682827
0.48538496317625707,0.23429707206217923
0.48527117538105624,0.23429707206217923
0.484872918097856,0.23437882278488378
0.4845315547122536,0.2347875763982322
0.4841332974290534,0.23495107784364128
0.48407640353145553,0.23503282856628768
0.4837350401458582,0.23535983145698963
0.48322299506745714,0.23560508362504518
0.4829385255794577,0.23593208651574712
0.48282473778425683,0.23593208651574712
0.4818575415250527,0.23601383723845165
0.4815161781394554,0.23625908940644907
0.4814023903442545,0.23634084012915363
0.4807196635730549,0.23683134446520654
0.48083345136825567,0.23691309518791107
0.4807765574706527,0.23707659663326205
0.480605875777854,0.23724009807861302
0.48054898188025624,0.23732184880131757
0.47958178562105197,0.23756710096931496
0.4791835283378517,0.2378123531373705
0.4791266344402539,0.2378123531373705
0.47861458936185286,0.238057605305426
0.4781594381810548,0.23838460819612795
0.47787496869305524,0.23838460819612795
0.47781807479545235,0.2384663589187744
0.4770784541266547,0.2386298603641254
0.47645262125305293,0.23838460819612795
0.4763957273554551,0.23830285747342342
0.4756561066866524,0.23838460819612795
0.47514406160825146,0.23871161108682992
0.47514406160825146,0.23871161108682992
0.47520095550585434,0.23895686325488544
0.4748595921202519,0.23903861397753187
0.4741768653490523,0.23961086903628934
0.4740630775538514,0.2396926197589939
0.4736079263730533,0.23985612120434485
0.4725838362162512,0.24001962264969584
0.47252694231865344,0.24001962264969584
0.472185578933051,0.23985612120434485
0.47207179113785025,0.23993787192699131
0.47201489724025236,0.2401013733723423
0.47207179113785025,0.24051012698574875
0.4722993667282518,0.24051012698574875
0.47241315452345256,0.24051012698574875
0.47286830570425076,0.2407553791538043
0.47326656298745096,0.2414093849352082
0.47326656298745096,0.24157288638055918
0.4733803507826517,0.2424721443300186
0.47349413857785255,0.24296264866612963
0.47349413857785255,0.24296264866612963
0.47349413857785255,0.24361665444753353
0.4733803507826517,0.24378015589288451
0.47286830570425076,0.24402540806094003
0.47286830570425076,0.24410715878364456
0.47252694231865344,0.24467941384234393
0.47241315452345256,0.24541517034645238
0.4723562606258497,0.2454969210691569
0.4719011094450516,0.2459056746825634
0.4717873216498507,0.24606917612791437
0.47173042775225293,0.24639617901861632
0.4715028521618513,0.24647792974132085
0.4715028521618513,0.2465596804639673
0.4717873216498507,0.2468049326320228
0.4715028521618513,0.24705018480002025
0.47121838267385185,0.2472136862454293
0.47099080708345026,0.2473771876907803
0.4709339131858524,0.24745893841342675
0.47070633759545083,0.24754068913613125
0.47019429251704975,0.24754068913613125
0.4700236108242511,0.24762243985877772
0.46973914133625166,0.24794944274953776
0.4696822474386488,0.2480311934721842
0.4695684596434479,0.2482764456402397
0.46934088405305147,0.24852169780823716
0.4692270962578506,0.2488487006989972
0.4690564145650519,0.24901220214434816
0.46899952066744904,0.24925745431234558
0.46899952066744904,0.2493392050350501
0.46865815728185173,0.24966620792575206
0.4683167938962493,0.24974795864845659
0.46820300610104854,0.24982970937110305
0.4680323244082499,0.2502384629845096
0.4680323244082499,0.2503202137072141
0.46797543051064694,0.2505654658752115
0.4676340671250496,0.25130122237931996
0.46752027932984885,0.25154647454737544
0.46752027932984885,0.25154647454737544
0.4672927037394473,0.25170997599272643
0.46672376476344835,0.25154647454737544
0.4660979318898465,0.25146472382467094
0.4659841440946507,0.25146472382467094
0.46507384173304944,0.250974219488618
0.46478937224505,0.250974219488618
0.46467558444984924,0.250974219488618
0.46404975157624745,0.2508924687659134
0.4634239187026456,0.25146472382467094
0.4633670248050477,0.25154647454737544
0.46319634311224905,0.25170997599272643
0.4630825553170483,0.25236398177413033
0.4629118736242445,0.25252748321953944
0.462684298033848,0.2524457324968349
0.462684298033848,0.25236398177413033
0.462684298033848,0.2522004803287794
0.46274119193144586,0.2519552281607819
0.46285497972664663,0.2518734774380774
0.4630256614194453,0.251791726715431
0.46331013090744483,0.2521187296061329
0.46325323700984694,0.2519552281607819
0.4633670248050477,0.25170997599272643
0.4633670248050477,0.2516282252700219
0.4634808126002485,0.2508924687659134
0.4638221759858458,0.2502384629845096
0.4638790698834488,0.250156712261805
0.4641635393714482,0.24974795864845659
0.4648462661426479,0.24942095575775464
0.46467558444984924,0.24925745431234558
0.4646186905522463,0.24917570358969915
0.4642204332690461,0.24917570358969915
0.4643342210642469,0.24925745431234558
0.4645617966546484,0.24893045142164363
0.46501694783544656,0.24876694997629265
0.46507384173304944,0.24876694997629265
0.465301417323446,0.24868519925364618
0.4654152051186468,0.24852169780823716
0.46547209901624975,0.24835819636288614
0.4655858868114505,0.2481946949175352
0.4656996746066463,0.2478676920268332
0.46581346240184707,0.24762243985877772
0.46587035629944995,0.24762243985877772
0.4659841440946507,0.24713193552272478
0.4659841440946507,0.24696843407737382
0.4659841440946507,0.2467231819093183
0.46621171968504727,0.24623267757326536
0.46626861358265026,0.2461509268505608
0.4663824013778459,0.24566042251450787
0.4663824013778459,0.24533341962380592
0.46678065866104623,0.24492466601039942
0.46678065866104623,0.24492466601039942
0.46695134035385,0.24467941384234393
0.46706512814905077,0.24435241095164198
0.4673495976370502,0.24410715878364456
0.46746338543225097,0.24386190661558904
0.46752027932984885,0.24378015589288451
0.4674064915346481,0.24361665444753353
0.4673495976370502,0.24345315300218254
0.4664392952754489,0.24386190661558904
0.4663824013778459,0.24386190661558904
0.46552899291384764,0.24410715878364456
0.465301417323446,0.2444341616743465
0.46507384173304944,0.24459766311969747
0.46501694783544656,0.24467941384234393
0.4649031600402458,0.24508816745575043
0.4643342210642469,0.2459056746825634
0.4642204332690461,0.2459056746825634
0.4637652820882479,0.24606917612791437
0.4633670248050477,0.2461509268505608
0.46285497972664663,0.24582392395985886
0.46279808582904874,0.24582392395985886
0.4623429346482456,0.24566042251450787
0.4620015712626483,0.24516991817845496
0.46177399567224675,0.24508816745575043
0.4616033139794481,0.24541517034645238
0.46148952618424727,0.2454969210691569
0.46103437500344413,0.24582392395985886
0.46097748110584624,0.24623267757326536
0.4606361177202438,0.2465596804639673
0.46052232992504816,0.24664143118667187
0.4606361177202438,0.24688668335466926
0.46057922382264593,0.24705018480002025
0.4607499055154446,0.24745893841342675
0.4604085421298473,0.24794944274953776
0.4603516482322444,0.2480311934721842
0.4599533909490441,0.24811294419488875
0.4595551336658439,0.24835819636288614
0.4593844519730452,0.2488487006989972
0.45932755807544734,0.2488487006989972
0.45915687638264363,0.2490939528669946
0.45824657402104746,0.24966620792575206
0.45824657402104746,0.24974795864845659
0.4575638472498426,0.2503202137072141
0.4572793777618432,0.250647216597916
0.4572224838642453,0.2507289673205625
0.456881120478643,0.2513829731020245
0.45671043878584433,0.25154647454737544
0.45665354488824644,0.2519552281607819
0.45665354488824644,0.25203697888342835
0.45636907540024196,0.2524457324968349
0.45642596929784485,0.2530997382782388
0.4563121815026441,0.25334499044629427
0.4562552876050462,0.2534267411689988
0.4560277120146446,0.25350849189164526
0.4557432425266451,0.2539172455050518
0.45511740965304337,0.2539989962276982
0.45511740965304337,0.2539989962276982
0.4548898340626417,0.2539172455050518
0.45471915236984306,0.2539172455050518
0.4546053645746423,0.2539989962276982
0.4540364255986433,0.2541624976731073
0.4537519561106439,0.2540807469504027
0.453695062213041,0.2540807469504027
0.45346748662264447,0.2540807469504027
0.45329680492984076,0.25457125128645564
0.452670972056244,0.2548165034545112
0.452614078158641,0.2548165034545112
0.4519882452850443,0.25514350634521316
0.45170377579704485,0.2554705092359151
0.4514762002066432,0.2554705092359151
0.4514193063090403,0.2553887585132687
0.4509641551282421,0.2554705092359151
0.45045211004984115,0.2557975121266751
0.4502814283570425,0.2557975121266751
0.45016764056184166,0.2557975121266751
0.44976938327864147,0.2559610135720261
0.4492573382002404,0.2557157614039706
0.4488590809170402,0.2557157614039706
0.4488021870194423,0.2557975121266751
0.44811946024824256,0.2559610135720261
0.4482901419410412,0.25604276429467254
0.44868839922424153,0.2558792628493216
0.44891597481464307,0.2558792628493216
0.44897286871224096,0.2558792628493216
0.44937112599544116,0.2554705092359151
0.4497124893810436,0.2547347527318067
0.44982627717623935,0.2547347527318067
0.45011074666424383,0.25448950056380926
0.4507365795378406,0.25375374405970075
0.45079347343544346,0.2536719933369963
0.4514193063090403,0.2526092339421858
0.4514762002066432,0.2524457324968349
0.4515330941042411,0.2519552281607819
0.45164688189944185,0.251791726715431
0.4518175635922405,0.2516282252700219
0.4522158208754408,0.25146472382467094
0.4523296086706416,0.25130122237931996
0.4523865025682445,0.25121947165667347
0.45250029036344025,0.250974219488618
0.4523296086706416,0.250647216597916
0.45244339646584236,0.2502384629845096
0.4523865025682445,0.25007496153915854
0.45244339646584236,0.24982970937110305
0.45244339646584236,0.24974795864845659
0.45215892697784293,0.24925745431234558
0.45204513918264216,0.24868519925364618
0.45204513918264216,0.24835819636288614
0.4519882452850443,0.2482764456402397
0.45204513918264216,0.2480311934721842
0.4519313513874413,0.24770419058148224
0.4519882452850443,0.2473771876907803
0.4519882452850443,0.2472136862454293
0.45210203308024505,0.24696843407737382
0.45215892697784293,0.24688668335466926
0.4522727147730437,0.24664143118667187
0.452614078158641,0.24623267757326536
0.45295544154424344,0.24574217323721242
0.45301233544184133,0.24566042251450787
0.45341059272504153,0.24557867179186146
0.4535243805202423,0.2454969210691569
0.4536381683154431,0.24508816745575043
0.4536381683154431,0.24467941384234393
0.45358127441784524,0.24459766311969747
0.4535243805202423,0.24435241095164198
0.45329680492984076,0.2431261501114806
0.45329680492984076,0.2431261501114806
0.45284165374904267,0.24271739649807408
0.452670972056244,0.24198163999396566
0.452670972056244,0.24189988927131922
0.452614078158641,0.24157288638055918
0.452670972056244,0.24083712987645073
0.4527847598514448,0.24067362843109974
0.4527278659538418,0.24042837626310234
0.4527278659538418,0.24042837626310234
0.4527847598514448,0.23993787192699131
0.45295544154424344,0.2396926197589939
0.4530692293394442,0.2391203647002364
0.4531261232370421,0.23903861397753187
0.4531261232370421,0.23895686325488544
0.4530692293394442,0.23879336180953445
0.4531261232370421,0.2386298603641254
0.4530692293394442,0.23830285747342342
0.45323991103224287,0.23789410386001697
0.45323991103224287,0.23756710096931496
0.45323991103224287,0.23748535024666856
0.453183017134645,0.2371583473559666
0.45329680492984076,0.23699484591055753
0.4535243805202423,0.2367495937425601
0.4536381683154431,0.23609558796109809
0.4536381683154431,0.23609558796109809
0.4536381683154431,0.23576858507039614
0.4535243805202423,0.2356868343477497
0.45329680492984076,0.2356868343477497
0.45301233544184133,0.23593208651574712
0.45284165374904267,0.23601383723845165
0.45284165374904267,0.23625908940644907
0.4527847598514448,0.23634084012915363
0.452614078158641,0.23650434157450456
0.4522158208754408,0.23666784301985558
0.45170377579704485,0.23732184880131757
0.45164688189944185,0.237403599523964
0.45130551851384454,0.23773060241466595
0.451077942923443,0.23813935602807246
0.451077942923443,0.23830285747342342
0.45102104902584006,0.2384663589187744
0.45090726123064434,0.23854810964147896
0.45085036733304135,0.23854810964147896
0.4506227917426398,0.23871161108682992
0.45045211004984115,0.2391203647002364
0.4497124893810436,0.2391203647002364
0.4495987015858428,0.2391203647002364
0.4492573382002404,0.23944736759093835
0.44902976260983885,0.2401013733723423
0.4488021870194423,0.2403466255403978
0.4488021870194423,0.2403466255403978
0.44817635414584045,0.24100063132185978
0.44811946024824256,0.24116413276721077
0.44794877855543885,0.24116413276721077
0.44783499076024313,0.2414093849352082
0.44783499076024313,0.24149113565791275
0.4477780968626402,0.24165463710326368
0.44760741516984154,0.2418181385486147
0.4470953700914405,0.24206339071667018
0.44681090060344103,0.24239039360737213
0.44675400670583815,0.2424721443300186
0.4465833250130395,0.24271739649807408
0.4461281738322413,0.24296264866612963
0.4460712799346384,0.24328965155683158
0.4460712799346384,0.24361665444753353
0.4460712799346384,0.24361665444753353
0.4460143860370405,0.24410715878364456
0.44590059824183975,0.2444341616743465
0.44544544706104167,0.24476116456504846
0.44538855316343867,0.244842915287753
0.4453316592658408,0.24508816745575043
0.44504718977784136,0.24533341962380592
0.444705826392239,0.24541517034645238
0.4443075691090388,0.24533341962380592
0.444193781313838,0.24533341962380592
0.44322658505463886,0.24516991817845496
0.4430559033618402,0.24525166890110142
0.443112797259438,0.2454969210691569
0.44316969115704097,0.2454969210691569
0.44316969115704097,0.24533341962380592
0.4426576460786399,0.24500641673310397
0.4420318132050381,0.24492466601039942
0.4419749193074402,0.24492466601039942
0.4411215108434368,0.24476116456504846
0.4407801474578395,0.24500641673310397
0.44055257186743785,0.24508816745575043
0.44049567796984007,0.24508816745575043
0.44009742068663976,0.24492466601039942
0.4391871183250385,0.24533341962380592
0.43913022442743554,0.24541517034645238
0.4388457549394361,0.24541517034645238
0.43873196714423535,0.24557867179186146
0.43782166478263906,0.24566042251450787
0.4377078769874383,0.24566042251450787
0.43736651360183587,0.24582392395985886
0.43702515021623856,0.2459056746825634
0.4367406807282341,0.24606917612791437
0.4364562112402346,0.2461509268505608
0.4363993173426367,0.24623267757326536
0.4360579539570344,0.24631442829591182
0.4350907576978352,0.24631442829591182
0.4349769699026344,0.24631442829591182
0.4345787126194342,0.24647792974132085
0.43361151636023504,0.2468049326320228
0.43355462246263715,0.2468049326320228
0.43292878958903536,0.2468049326320228
0.43213227502263485,0.24705018480002025
0.43207538112503185,0.24705018480002025
0.43133576045623434,0.2473771876907803
0.43122197266103357,0.24754068913613125
0.4311650787634357,0.24770419058148224
0.4309943970706319,0.2478676920268332
0.43093750317303403,0.24794944274953776
0.43053924588983383,0.2480311934721842
0.43042545809463306,0.2481946949175352
0.4302547764018344,0.2482764456402397
0.4301409886066336,0.24852169780823716
0.42979962522103127,0.24868519925364618
0.4297427313234334,0.24876694997629265
0.4296289435282326,0.24893045142164363
0.4292875801426353,0.24917570358969915
0.428889322859435,0.24917570358969915
0.42854795947383256,0.2495027064804011
0.4284910655762348,0.2495027064804011
0.4280928082930345,0.24966620792575206
0.4277514449074321,0.24991146009380757
0.42758076321463345,0.24991146009380757
0.42752386931703057,0.2504019644298605
0.4274100815218348,0.25048371515256507
0.4272393998290311,0.25048371515256507
0.4264997791602335,0.2504019644298605
0.42610152187703326,0.2502384629845096
0.42598773408183244,0.250156712261805
0.4258170523890338,0.250156712261805
0.42564637069623007,0.24999321081645404
0.4252481134130298,0.24991146009380757
0.4247360683346288,0.24999321081645404
0.4246791744370309,0.24999321081645404
0.4242240232562328,0.24991146009380757
0.4240533415634291,0.24974795864845659
0.4237119781778318,0.24974795864845659
0.42337061479222937,0.24958445720310563
0.42331372089463154,0.24958445720310563
0.42337061479222937,0.24974795864845659
0.4231999330994307,0.24982970937110305
0.4229154636114313,0.2495027064804011
0.4223465246354324,0.2493392050350501
0.42228963073782944,0.24925745431234558
0.42166379786422764,0.2490939528669946
0.42115175278583167,0.24876694997629265
0.42092417719543007,0.24876694997629265
0.4208672832978322,0.24876694997629265
0.42046902601463193,0.2488487006989972
0.41955872365303065,0.24860344853094166
0.41955872365303065,0.24860344853094166
0.41933114806262906,0.24868519925364618
0.4189897846770267,0.24876694997629265
0.41870531518902726,0.24852169780823716
0.4185346334962286,0.24852169780823716
0.4182501640082291,0.24868519925364618
0.4181932701106262,0.24868519925364618
0.41694160436342753,0.24835819636288614
0.4168847104658297,0.24835819636288614
0.4164295592850265,0.2480311934721842
0.41625887759222785,0.24811294419488875
0.4161450897970271,0.2482764456402397
0.4160313020018263,0.24868519925364618
0.4160313020018263,0.24868519925364618
0.4157468325138268,0.24942095575775464
0.41557615082102817,0.24999321081645404
0.41557615082102817,0.25007496153915854
0.41557615082102817,0.2503202137072141
0.4154054691282295,0.2504019644298605
0.41534857523062657,0.25105597021132253
0.4152347874354258,0.25130122237931996
0.4152347874354258,0.2513829731020245
0.4147227423570247,0.2519552281607819
0.4144382728690253,0.2524457324968349
0.4144382728690253,0.2526092339421858
0.4143813789714274,0.25301798755559235
0.41421069727862875,0.2530997382782388
0.4135848644050269,0.25350849189164526
0.41352797050742907,0.25350849189164526
0.413015925429028,0.25383549478234724
0.41284524373622433,0.2539989962276982
0.41273145594102856,0.25383549478234724
0.4122763047602254,0.25383549478234724
0.41221941086262753,0.2539172455050518
0.41199183527222594,0.25383549478234724
0.41187804747702517,0.2536719933369963
0.41165047188662357,0.25350849189164526
0.41096774511542383,0.25350849189164526
0.41096774511542383,0.25350849189164526
0.4097729732658231,0.25334499044629427
0.4097160793682253,0.2530997382782388
0.4096591854706274,0.25301798755559235
0.40994365495862684,0.2524457324968349
0.41028501834422415,0.2522004803287794
0.4103988061394249,0.2518734774380774
0.41045570003702786,0.2518734774380774
0.41074016952502734,0.25154647454737544
0.4107970634226252,0.250810718043267
0.41096774511542383,0.2505654658752115
0.41096774511542383,0.25048371515256507
0.4111384268082276,0.24999321081645404
0.41119532070582543,0.2493392050350501
0.41130910850102625,0.24917570358969915
0.4113660023986241,0.2490939528669946
0.41153668409142785,0.24893045142164363
0.4115935779890257,0.24876694997629265
0.4114797901938249,0.2484399470855907
0.41165047188662357,0.2477859413041287
0.41165047188662357,0.24770419058148224
0.41153668409142785,0.24754068913613125
0.4114797901938249,0.2472136862454293
0.41153668409142785,0.2465596804639673
0.41153668409142785,0.24631442829591182
0.4114797901938249,0.24623267757326536
0.4117073657842265,0.24557867179186146
0.4120487291698238,0.24508816745575043
0.41221941086262753,0.24492466601039942
0.41221941086262753,0.24492466601039942
0.4123900925554262,0.24467941384234393
0.412503880350627,0.24427066022899552
0.4123900925554262,0.24410715878364456
0.4126176681458278,0.24386190661558904
0.412503880350627,0.24410715878364456
0.4124469864530241,0.24410715878364456
0.4120487291698238,0.24451591239699297
0.4117073657842265,0.24467941384234393
0.41130910850102625,0.24492466601039942
0.4112522146034233,0.24500641673310397
0.41102463901302677,0.24516991817845496
0.4106832756274244,0.24525166890110142
0.4100005488562247,0.24574217323721242
0.40994365495862684,0.24574217323721242
0.4086919892114232,0.24647792974132085
0.4086350953138253,0.24647792974132085
0.40857820141622236,0.24631442829591182
0.4086350953138253,0.2461509268505608
0.4089195648018248,0.24574217323721242
0.40903335259702556,0.24533341962380592
0.40903335259702556,0.24533341962380592
0.40903335259702556,0.24508816745575043
0.4092040342898242,0.24459766311969747
0.4092040342898242,0.24435241095164198
0.40931782208502504,0.24394365733823548
0.40937471598262287,0.24386190661558904
0.40954539767542664,0.24369840517023808
0.4097729732658231,0.24296264866612963
0.4097729732658231,0.24255389505272312
0.4097729732658231,0.2424721443300186
0.40982986716342606,0.24222689216202117
0.41022812444662626,0.24206339071667018
0.41045570003702786,0.24157288638055918
0.41022812444662626,0.24157288638055918
0.4101712305490234,0.24165463710326368
0.4097160793682253,0.24206339071667018
0.4092040342898242,0.24230864288466764
0.4088626709042269,0.24230864288466764
0.40880577700662396,0.24222689216202117
0.4080661563378264,0.24255389505272312
0.4075541112594253,0.24296264866612963
0.4075541112594253,0.2430443993887761
0.4072696417714259,0.24328965155683158
0.40709896007862223,0.24337140227953613
0.4068713844882257,0.24328965155683158
0.40675759669302486,0.24345315300218254
0.40653002110262326,0.24345315300218254
0.40647312720502543,0.24361665444753353
0.405619718741022,0.244188909506291
0.40550593094582116,0.24435241095164198
0.4051076736626209,0.24451591239699297
0.4048232041746215,0.24476116456504846
0.40459562858422493,0.24516991817845496
0.40453873468662205,0.24525166890110142
0.40431115909622045,0.24541517034645238
0.4042542651986226,0.24566042251450787
0.404026689608221,0.24598742540520985
0.4035715384274229,0.24639617901861632
0.4035715384274229,0.24639617901861632
0.40317328114422263,0.24688668335466926
0.40294570555382103,0.24688668335466926
0.4028888116562232,0.24664143118667187
0.40300259945142397,0.24639617901861632
0.40305949334902186,0.24631442829591182
0.40328706893942345,0.24623267757326536
0.4034577506322221,0.2459056746825634
0.4034008567346242,0.24525166890110142
0.40351464452982505,0.24500641673310397
0.40351464452982505,0.24500641673310397
0.40351464452982505,0.24467941384234393
0.4034577506322221,0.24492466601039942
0.4034008567346242,0.24476116456504846
0.4034008567346242,0.244188909506291
0.40317328114422263,0.24435241095164198
0.40317328114422263,0.2444341616743465
0.40305949334902186,0.24467941384234393
0.4026612360658216,0.24500641673310397
0.40226297878262135,0.24557867179186146
0.40220608488502346,0.24557867179186146
0.40163714590901956,0.2459056746825634
0.40135267642102007,0.24639617901861632
0.40106820693302064,0.24647792974132085
0.40101131303542276,0.2465596804639673
0.40049926795702173,0.24688668335466926
0.4003285862642231,0.2468049326320228
0.4006699496498204,0.2465596804639673
0.40049926795702173,0.24631442829591182
0.40049926795702173,0.24623267757326536
0.40055616185461956,0.24582392395985886
0.4006699496498204,0.24598742540520985
0.40084063134261905,0.24606917612791437
0.40078373744502116,0.2459056746825634
0.40078373744502116,0.2454969210691569
0.40078373744502116,0.24541517034645238
0.40101131303542276,0.24476116456504846
0.4011251008306235,0.24402540806094003
0.4011251008306235,0.24394365733823548
0.4012957825234222,0.24345315300218254
0.4011819947282214,0.2431261501114806
0.40101131303542276,0.2431261501114806
0.40072684354742333,0.24345315300218254
0.4006699496498204,0.24353490372488706
0.3995320716978225,0.24451591239699297
0.3994751778002196,0.24451591239699297
0.3989631327218186,0.24492466601039942
0.3983941937458197,0.24500641673310397
0.3981666181554181,0.24492466601039942
0.3980528303602224,0.24492466601039942
0.3976545730770221,0.24508816745575043
0.3974838913842184,0.24516991817845496
0.3974269974866205,0.24541517034645238
0.39725631579382187,0.2454969210691569
0.3968580585106216,0.2454969210691569
0.3968011646130187,0.24557867179186146
0.39628911953461765,0.24631442829591182
0.3961753317394219,0.24664143118667187
0.3958908622514174,0.2467231819093183
0.3958339683538195,0.2468049326320228
0.3953219232754185,0.24696843407737382
0.3952081354802177,0.2465596804639673
0.3953219232754185,0.2461509268505608
0.3953219232754185,0.2459056746825634
0.3953219232754185,0.24582392395985886
0.39560639276341797,0.24541517034645238
0.39628911953461765,0.24492466601039942
0.3964029073298184,0.24476116456504846
0.3964029073298184,0.24476116456504846
0.3964029073298184,0.24467941384234393
0.396118437841819,0.24459766311969747
0.39600465004661817,0.24467941384234393
0.3958339683538195,0.24467941384234393
0.3955494988658201,0.24492466601039942
0.3952081354802177,0.24525166890110142
0.3952081354802177,0.24533341962380592
0.39480987819701746,0.24606917612791437
0.39446851481142015,0.2461509268505608
0.3943547270162193,0.24639617901861632
0.3942978331186164,0.24647792974132085
0.39344442465461804,0.24754068913613125
0.39338753075702015,0.24762243985877772
0.3933306368594172,0.24811294419488875
0.3931030612690207,0.2484399470855907
0.39287548567861913,0.24893045142164363
0.39281859178101625,0.24901220214434816
0.39247722839541893,0.24925745431234558
0.39224965280501733,0.24974795864845659
0.39190828941942,0.25007496153915854
0.39190828941942,0.250156712261805
0.39151003213621977,0.2505654658752115
0.39111177485301946,0.25130122237931996
0.39111177485301946,0.2513829731020245
0.3909979870578187,0.2516282252700219
0.3907704114674171,0.2518734774380774
0.3907704114674171,0.2521187296061329
0.3905428358770155,0.25236398177413033
0.3904290480818147,0.2526909846648904
0.3904290480818147,0.2526909846648904
0.3900876846962174,0.25326323972364784
0.3900307907986145,0.25301798755559235
0.3901445785938153,0.2529362368328878
0.3901445785938153,0.2526909846648904
0.3900307907986145,0.25252748321953944
0.3900307907986145,0.2524457324968349
0.389803215208218,0.2522822310514839
0.3894618518226156,0.2526909846648904
0.38894980674421453,0.2530997382782388
0.38889291284661665,0.2530997382782388
0.3884946555634164,0.2534267411689988
0.3882670799730148,0.2534267411689988
0.3884946555634164,0.2530997382782388
0.3884946555634164,0.2529362368328878
0.3884946555634164,0.25285448611024136
0.38843776166581856,0.2526092339421858
0.3885515494610143,0.2524457324968349
0.38843776166581856,0.2521187296061329
0.3885515494610143,0.251791726715431
0.3883808677682156,0.2516282252700219
0.38832397387061773,0.25170997599272643
0.38832397387061773,0.251791726715431
0.388153292177814,0.2522004803287794
0.3879257165874175,0.25252748321953944
0.3879257165874175,0.2526909846648904
0.38775503489461377,0.25285448611024136
0.3875274593042172,0.25285448611024136
0.3875274593042172,0.25285448611024136
0.38747056540661434,0.2526909846648904
0.38764124709941805,0.25252748321953944
0.38764124709941805,0.25236398177413033
0.3875843532018151,0.2522004803287794
0.38764124709941805,0.2519552281607819
0.3875843532018151,0.251791726715431
0.38775503489461377,0.25154647454737544
0.38775503489461377,0.25146472382467094
0.3879257165874175,0.25121947165667347
0.3879257165874175,0.250647216597916
0.3882670799730148,0.2504019644298605
0.3882670799730148,0.25007496153915854
0.3882670799730148,0.25007496153915854
0.38821018607541696,0.24982970937110305
0.38786882268981454,0.24999321081645404
0.38747056540661434,0.24999321081645404
0.38747056540661434,0.24958445720310563
0.38747056540661434,0.24958445720310563
0.3872998837138157,0.24893045142164363
0.3870723081234141,0.2488487006989972
0.38690162643061543,0.2488487006989972
0.3869585203282133,0.24925745431234558
0.3869585203282133,0.2493392050350501
0.38678783863541466,0.24974795864845659
0.38656026304501306,0.25007496153915854
0.3865033691474152,0.2502384629845096
0.3863326874546165,0.250156712261805
0.3863326874546165,0.24991146009380757
0.3863326874546165,0.24982970937110305
0.38621889965941575,0.2495027064804011
0.3862757935570136,0.2493392050350501
0.3861620057618128,0.24876694997629265
0.3861620057618128,0.24835819636288614
0.3861620057618128,0.2482764456402397
0.3861051118642149,0.2480311934721842
0.38599132406901415,0.2478676920268332
0.3858775362738133,0.24794944274953776
0.3858206423762155,0.2484399470855907
0.38570685458101467,0.24852169780823716
0.3854792789906131,0.24852169780823716
0.38542238509301524,0.24860344853094166
0.38513791560501576,0.24917570358969915
0.3852517034002166,0.24974795864845659
0.3850810217074128,0.24991146009380757
0.385024127809815,0.24991146009380757
0.385024127809815,0.24999321081645404
0.3847965522194134,0.24999321081645404
0.3846827644242126,0.24982970937110305
0.38462587052661473,0.24958445720310563
0.38485344611701633,0.24966620792575206
0.38485344611701633,0.2495027064804011
0.38485344611701633,0.24925745431234558
0.3846827644242126,0.2490939528669946
0.3846827644242126,0.24901220214434816
0.38462587052661473,0.2484399470855907
0.3846827644242126,0.2480311934721842
0.38451208273141396,0.2477859413041287
0.3844551888338161,0.2477859413041287
0.3843982949362132,0.2477859413041287
0.3841707193458166,0.24917570358969915
0.3841138254482137,0.24925745431234558
0.3838293559602143,0.24982970937110305
0.38371556816501345,0.24991146009380757
0.3835448864722148,0.24982970937110305
0.3836017803698127,0.24942095575775464
0.3836586742674156,0.2493392050350501
0.38348799257461186,0.24917570358969915
0.3828621597010151,0.24958445720310563
0.3827483719058143,0.24942095575775464
0.3828621597010151,0.24925745431234558
0.3828621597010151,0.24917570358969915
0.3829759474962159,0.2488487006989972
0.3829759474962159,0.2484399470855907
0.3828052658034122,0.2484399470855907
0.38246390241781486,0.24876694997629265
0.3824070085202119,0.24876694997629265
0.3821794329298154,0.24917570358969915
0.3821794329298154,0.24901220214434816
0.3824070085202119,0.24868519925364618
0.3827483719058143,0.2484399470855907
0.3827483719058143,0.2484399470855907
0.38257769021301563,0.2484399470855907
0.38246390241781486,0.24860344853094166
0.3817811756466151,0.24893045142164363
0.3816104939538114,0.2490939528669946
0.3815536000562135,0.2490939528669946
0.38143981226101276,0.2495027064804011
0.38143981226101276,0.24991146009380757
0.3815536000562135,0.24974795864845659
0.3816104939538114,0.2493392050350501
0.3816104939538114,0.24925745431234558
0.38143981226101276,0.2490939528669946
0.3812691305682141,0.2490939528669946
0.38109844887541544,0.24917570358969915
0.38104155497781256,0.2493392050350501
0.3808708732850139,0.2495027064804011
0.3808708732850139,0.2490939528669946
0.3808708732850139,0.24901220214434816
0.3808708732850139,0.24860344853094166
0.38081397938741096,0.24835819636288614
0.38018814651381416,0.24893045142164363
0.3801312526162112,0.24893045142164363
0.38001746482101045,0.24917570358969915
0.37996057092341257,0.2495027064804011
0.3798467831282118,0.24982970937110305
0.3796761014354131,0.24999321081645404
0.37939163194741365,0.25007496153915854
0.3793347380498107,0.25007496153915854
0.37927784415221283,0.24999321081645404
0.37944852584501154,0.24982970937110305
0.3795623136402123,0.24893045142164363
0.3796761014354131,0.24876694997629265
0.3796761014354131,0.24868519925364618
0.3797898892306139,0.2484399470855907
0.37996057092341257,0.2484399470855907
0.38018814651381416,0.24811294419488875
0.3807001915922152,0.2477859413041287
0.3807001915922152,0.2477859413041287
0.38092776718261173,0.24762243985877772
0.38104155497781256,0.24745893841342675
0.38115534277301333,0.24729543696807577
0.381326024465812,0.24647792974132085
0.381326024465812,0.24647792974132085
0.38149670615861564,0.24582392395985886
0.38172428174901224,0.2454969210691569
0.381838069544213,0.24500641673310397
0.381838069544213,0.24492466601039942
0.3818949634418159,0.24467941384234393
0.38166738785141435,0.24459766311969747
0.381326024465812,0.24508816745575043
0.38115534277301333,0.24516991817845496
0.3812122366706112,0.24533341962380592
0.3812122366706112,0.24533341962380592
0.38104155497781256,0.24541517034645238
0.38092776718261173,0.24582392395985886
0.3806432976946123,0.24606917612791437
0.3805295098994115,0.24623267757326536
0.3803588282066128,0.24639617901861632
0.38030193430901493,0.24631442829591182
0.38024504041141205,0.24606917612791437
0.3805864037970144,0.24582392395985886
0.3806432976946123,0.24566042251450787
0.3806432976946123,0.2454969210691569
0.3805295098994115,0.24533341962380592
0.3803588282066128,0.24525166890110142
0.3803588282066128,0.24525166890110142
0.37996057092341257,0.2454969210691569
0.37996057092341257,0.24582392395985886
0.3799036770258147,0.24598742540520985
0.37944852584501154,0.24623267757326536
0.37939163194741365,0.24623267757326536
0.3788795868690126,0.24647792974132085
0.3787089051762139,0.24688668335466926
0.3783675417906116,0.24705018480002025
0.3783106478930137,0.24713193552272478
0.3780830723026121,0.24713193552272478
0.37768481501941187,0.24713193552272478
0.37694519435060925,0.24745893841342675
0.37688830045301136,0.24754068913613125
0.37660383096501193,0.24762243985877772
0.3764331492722133,0.24754068913613125
0.37631936147701245,0.24729543696807577
0.3770020882482122,0.24696843407737382
0.37705898214581,0.24688668335466926
0.3773434516338095,0.2465596804639673
0.37711587604341296,0.2465596804639673
0.37694519435060925,0.2467231819093183
0.37660383096501193,0.2468049326320228
0.37637625537461034,0.24688668335466926
0.37631936147701245,0.24696843407737382
0.3758073163986114,0.24713193552272478
0.3750676957298088,0.24754068913613125
0.37501080183221097,0.24754068913613125
0.37523837742261257,0.24696843407737382
0.3754090591154112,0.24664143118667187
0.3754090591154112,0.24647792974132085
0.37518148352500963,0.24664143118667187
0.37518148352500963,0.24664143118667187
0.3747832262418094,0.24705018480002025
0.3743849689586091,0.24729543696807577
0.37410049947060964,0.24762243985877772
0.37410049947060964,0.24762243985877772
0.3740436055730118,0.24729543696807577
0.37410049947060964,0.24713193552272478
0.37455565065141283,0.24639617901861632
0.3746125445490107,0.24631442829591182
0.3747832262418094,0.24606917612791437
0.37466943844660855,0.2459056746825634
0.3741573933682126,0.24606917612791437
0.373929817777811,0.2461509268505608
0.3738160299826102,0.24639617901861632
0.3738160299826102,0.24639617901861632
0.3735884543922086,0.24647792974132085
0.37313330321141047,0.24696843407737382
0.3725643642354116,0.2472136862454293
0.3725074703378087,0.24729543696807577
0.3722798947474071,0.24729543696807577
0.37222300084980925,0.24713193552272478
0.3722798947474071,0.24688668335466926
0.37279193982580816,0.24664143118667187
0.37290572762100893,0.24647792974132085
0.37284883372341104,0.24639617901861632
0.3726212581330095,0.24631442829591182
0.3724505764402108,0.24639617901861632
0.3723936825426079,0.2465596804639673
0.371824743566609,0.2467231819093183
0.37193853136180977,0.24647792974132085
0.37199542525940765,0.24647792974132085
0.37233678864501,0.24623267757326536
0.3725643642354116,0.2459056746825634
0.3730764093138076,0.2454969210691569
0.3730764093138076,0.2454969210691569
0.37364534828981155,0.24492466601039942
0.3737022421874094,0.24459766311969747
0.3737022421874094,0.24435241095164198
0.3737022421874094,0.24427066022899552
0.3737591360850123,0.24378015589288451
0.37330398490420913,0.24435241095164198
0.3730195154162097,0.24451591239699297
0.37290572762100893,0.24451591239699297
0.37233678864501,0.24467941384234393
0.3718816374642119,0.24500641673310397
0.37165406187381034,0.24500641673310397
0.3715971679762074,0.24500641673310397
0.371824743566609,0.24476116456504846
0.37193853136180977,0.24459766311969747
0.37199542525940765,0.24402540806094003
0.3717678496690111,0.24402540806094003
0.37171095577140817,0.24402540806094003
0.37165406187381034,0.24427066022899552
0.37136959238581085,0.24451591239699297
0.37136959238581085,0.24435241095164198
0.37171095577140817,0.24386190661558904
0.37171095577140817,0.24378015589288451
0.3720523191570106,0.24361665444753353
0.3723936825426079,0.2431261501114806
0.3724505764402108,0.24279914722077864
0.3724505764402108,0.24271739649807408
0.3726212581330095,0.2424721443300186
0.3729626215186118,0.2424721443300186
0.37336087880181207,0.24230864288466764
0.37341777269940996,0.24206339071667018
0.3734746665970078,0.24189988927131922
0.37341777269940996,0.2418181385486147
0.37319019710900836,0.24165463710326368
0.37267815203060733,0.24189988927131922
0.3721092130546084,0.24198163999396566
0.3720523191570106,0.24206339071667018
0.3717678496690111,0.24239039360737213
0.37136959238581085,0.2430443993887761
0.37114201679540926,0.2430443993887761
0.37114201679540926,0.24296264866612963
0.37114201679540926,0.24288089794342507
0.37136959238581085,0.24214514143931665
0.37142648628340874,0.24149113565791275
0.37142648628340874,0.24149113565791275
0.37165406187381034,0.2412458834898572
0.37216610695221136,0.24116413276721077
0.3721092130546084,0.24100063132185978
0.37233678864501,0.24067362843109974
0.3723936825426079,0.2405918777084533
0.3726212581330095,0.2397743704816403
0.3725643642354116,0.2391203647002364
0.3725074703378087,0.23903861397753187
0.3725074703378087,0.23879336180953445
0.37267815203060733,0.2384663589187744
0.3727350459282103,0.23797585458272147
0.3726212581330095,0.23773060241466595
0.3725643642354116,0.23773060241466595
0.3725074703378087,0.23773060241466595
0.371824743566609,0.23879336180953445
0.3717678496690111,0.2388751125321809
0.3710282290002085,0.2392838661455874
0.3708006534098069,0.2396926197589939
0.3709713351026106,0.23985612120434485
0.3709713351026106,0.23985612120434485
0.3705161839218074,0.2401831240950468
0.36994724494580855,0.2401831240950468
0.36971966935540695,0.2403466255403978
0.36966277545780907,0.24042837626310234
0.3695489876626083,0.2405918777084533
0.3692645181746088,0.24132763421256173
0.36915073037940804,0.2414093849352082
0.36892315478900645,0.2412458834898572
0.3689800486866094,0.24116413276721077
0.36920762427700593,0.24083712987645073
0.3694920937650104,0.23993787192699131
0.3695489876626083,0.23985612120434485
0.36971966935540695,0.2391203647002364
0.3696058815602062,0.2388751125321809
0.36971966935540695,0.2384663589187744
0.36977656325300984,0.23838460819612795
0.36994724494580855,0.23756710096931496
0.36977656325300984,0.23756710096931496
0.36966277545780907,0.23797585458272147
0.36966277545780907,0.238057605305426
0.36937830596980964,0.23830285747342342
0.36863868530100696,0.23895686325488544
0.36858179140340913,0.23903861397753187
0.36829732191540965,0.2392838661455874
0.3677852768370086,0.2401013733723423
0.3677283829394057,0.2401831240950468
0.36761459514420997,0.2403466255403978
0.36744391345140626,0.24051012698574875
0.3672732317586076,0.24051012698574875
0.3671025500658089,0.24042837626310234
0.3673301256562055,0.2401013733723423
0.36744391345140626,0.2397743704816403
0.36750080734900914,0.2396926197589939
0.36761459514420997,0.23895686325488544
0.367557701246607,0.23838460819612795
0.367557701246607,0.23830285747342342
0.367557701246607,0.23764885169201952
0.36784217073460646,0.23683134446520654
0.36784217073460646,0.23683134446520654
0.36784217073460646,0.23650434157450456
0.36812664022260594,0.23625908940644907
0.36829732191540965,0.23576858507039614
0.3682404280178067,0.23552333290234062
0.3682404280178067,0.2354415821796942
0.3684111097106105,0.23470582567558573
0.3682404280178067,0.23454232423023477
0.36806974632500805,0.23470582567558573
0.3684111097106105,0.23495107784364128
0.3684111097106105,0.23495107784364128
0.3678990646322094,0.23552333290234062
0.36761459514420997,0.23576858507039614
0.36738701955380837,0.23585033579310066
0.3672732317586076,0.23585033579310066
0.367045656168206,0.23593208651574712
0.36687497447540734,0.23650434157450456
0.36664739888500575,0.23650434157450456
0.36641982329460926,0.23609558796109809
0.36641982329460926,0.23609558796109809
0.36630603549940843,0.2356868343477497
0.36619224770420766,0.23552333290234062
0.36619224770420766,0.23527808073434323
0.36607845990900684,0.23511457928899224
0.36573709652340447,0.2354415821796942
0.36573709652340447,0.2354415821796942
0.36562330872820875,0.23585033579310066
0.36528194534260633,0.23683134446520654
0.36528194534260633,0.23691309518791107
0.36516815754740556,0.23756710096931496
0.3649974758546069,0.23789410386001697
0.36482679416180824,0.23797585458272147
0.36482679416180824,0.23773060241466595
0.3648836880594061,0.23764885169201952
0.3648836880594061,0.237403599523964
0.3647699002642053,0.23724009807861302
0.36459921857140665,0.23732184880131757
0.36459921857140665,0.23773060241466595
0.36437164298100505,0.23813935602807246
0.36437164298100505,0.238221106750777
0.36437164298100505,0.2384663589187744
0.364428536878608,0.23903861397753187
0.36431474908340716,0.23920211542288286
0.3641440673906085,0.2391203647002364
0.36397338569780485,0.2388751125321809
0.36397338569780485,0.23879336180953445
0.363859597902604,0.23854810964147896
0.363859597902604,0.23838460819612795
0.3637458101074083,0.237403599523964
0.3638027040050062,0.237403599523964
0.3637458101074083,0.23707659663326205
0.3638027040050062,0.23691309518791107
0.36363202231220754,0.23699484591055753
0.3635182345170067,0.23707659663326205
0.36340444672180594,0.23748535024666856
0.3632906589266051,0.23764885169201952
0.3632337650290073,0.23773060241466595
0.36283550774580703,0.238221106750777
0.3627217199506062,0.23895686325488544
0.3627217199506062,0.23903861397753187
0.36260793215540543,0.23985612120434485
0.36238035656500384,0.24042837626310234
0.36232346266740595,0.24051012698574875
0.3619252053842057,0.24108238204450624
0.3618114175890049,0.24132763421256173
0.3616407358962062,0.2414093849352082
0.36152694810100544,0.24116413276721077
0.36147005420340755,0.24116413276721077
0.3616976297938041,0.2403466255403978
0.36158384199860333,0.2396926197589939
0.36152694810100544,0.2396926197589939
0.36141316030580467,0.23944736759093835
0.36152694810100544,0.2391203647002364
0.36141316030580467,0.23879336180953445
0.361242478613006,0.2386298603641254
0.36107179692020736,0.2388751125321809
0.36107179692020736,0.2388751125321809
0.36107179692020736,0.2396926197589939
0.3609580091250066,0.23985612120434485
0.36101490302260447,0.24026487481775136
0.3609580091250066,0.2403466255403978
0.360730433534605,0.24083712987645073
0.36055975184180633,0.24100063132185978
0.3603890701490026,0.24083712987645073
0.3602183884562039,0.24051012698574875
0.3601614945586061,0.24042837626310234
0.36010460066100314,0.23936561686823385
0.35999081286580237,0.23903861397753187
0.35999081286580237,0.23895686325488544
0.3599339189682045,0.23871161108682992
0.359649449480205,0.23797585458272147
0.3595925555826021,0.23756710096931496
0.3595925555826021,0.23748535024666856
0.359649449480205,0.2367495937425601
0.35982013117300365,0.23609558796109809
0.3598770250706066,0.23601383723845165
0.36004770676340525,0.23560508362504518
0.35982013117300365,0.23552333290234062
0.3597063433778029,0.23593208651574712
0.35936497999220557,0.2361773386838026
0.35936497999220557,0.23625908940644907
0.35902361660660315,0.23634084012915363
0.3587960410162016,0.2367495937425601
0.3583408898354035,0.23724009807861302
0.35828399593780563,0.23732184880131757
0.35805642034740404,0.23764885169201952
0.35771505696180167,0.2378123531373705
0.3576581630642038,0.23797585458272147
0.357544375269003,0.23838460819612795
0.357544375269003,0.23838460819612795
0.3574305874738022,0.2386298603641254
0.35737369357620435,0.23895686325488544
0.3573167996786014,0.2391203647002364
0.3573167996786014,0.2395291183136429
0.35725990578100353,0.2397743704816403
0.3572030118834057,0.2397743704816403
0.3569754362930041,0.23944736759093835
0.3566909668050046,0.23895686325488544
0.3565202851122009,0.23936561686823385
0.356463391214603,0.23936561686823385
0.3564064973170052,0.23961086903628934
0.3564064973170052,0.23993787192699131
0.3562358156242014,0.2401013733723423
0.3561789217266036,0.23985612120434485
0.3561789217266036,0.2396926197589939
0.35612202782900065,0.23944736759093835
0.35606513393140277,0.23944736759093835
0.3558375583410012,0.23936561686823385
0.35566687664820257,0.2396926197589939
0.3550410437746007,0.2401831240950468
0.3549841498770029,0.24026487481775136
0.35481346818420423,0.2403466255403978
0.35452899869620474,0.24067362843109974
0.35435831700340104,0.24067362843109974
0.3539600597202008,0.2407553791538043
0.35373248412980424,0.2407553791538043
0.35361869633460347,0.2407553791538043
0.35282218176820296,0.24067362843109974
0.35282218176820296,0.24051012698574875
0.3531066512562024,0.2401831240950468
0.3531635451538003,0.2401831240950468
0.3531635451538003,0.23993787192699131
0.3529928634610016,0.2397743704816403
0.35196877330419957,0.23993787192699131
0.3519118794066017,0.23993787192699131
0.35117225873779906,0.2395291183136429
0.35111536484020117,0.23920211542288286
0.3509446831474025,0.23903861397753187
0.3508877892497996,0.23903861397753187
0.3507171075570009,0.2386298603641254
0.3507740014545988,0.2384663589187744
0.35100157704500035,0.2384663589187744
0.3515705160209993,0.238221106750777
0.3515705160209993,0.23813935602807246
0.3517980916114009,0.23797585458272147
0.3523670305873998,0.23813935602807246
0.35265150007539925,0.23797585458272147
0.35282218176820296,0.2378123531373705
0.35282218176820296,0.23789410386001697
0.35293596956340373,0.237403599523964
0.3529928634610016,0.23764885169201952
0.3531066512562024,0.23756710096931496
0.3530497573585995,0.23748535024666856
0.35293596956340373,0.23789410386001697
0.35259460617780136,0.23789410386001697
0.35219634889460116,0.2378123531373705
0.35202566720180245,0.23756710096931496
0.35202566720180245,0.23756710096931496
0.35185498550899874,0.23724009807861302
0.351741197713803,0.23707659663326205
0.3515136221234014,0.23707659663326205
0.3513998343282006,0.23691309518791107
0.35128604653299983,0.23650434157450456
0.35122915263540194,0.23642259085185816
0.35066021365940303,0.23503282856628768
0.3506033197618001,0.23495107784364128
0.3502619563762028,0.23446057350753022
0.3500343807858012,0.23364306628077533
0.3500343807858012,0.2335613155580708
0.3502619563762028,0.23331606339007338
0.35111536484020117,0.23388831844877275
0.35122915263540194,0.23413357061682827
0.35122915263540194,0.23413357061682827
0.35134294043060277,0.23405181989412374
0.3513998343282006,0.23380656772612632
0.35117225873779906,0.23347956483542437
0.35117225873779906,0.23282555905396235
0.35117225873779906,0.23274380833131592
0.35111536484020117,0.2324985561632604
0.35128604653299983,0.23233505471790944
0.35122915263540194,0.23159929821380099
0.35117225873779906,0.23135404604574544
0.35111536484020117,0.23127229532309904
0.3509446831474025,0.23086354170969253
0.3502619563762028,0.23020953592823054
0.35020506247859984,0.23020953592823054
0.34935165401460155,0.2301277852055841
0.3492947601169986,0.22996428376023312
0.34889650283379836,0.2297190315921776
0.3488396089362005,0.2297190315921776
0.34827066996020156,0.22922852725612466
0.3482137760625986,0.22898327508806915
0.3481568821650008,0.22857452147466267
0.3481568821650008,0.22857452147466267
0.34832756385779945,0.2284927707520162
0.3484982455505981,0.2280840171386097
0.3486120333457989,0.22792051569325877
0.34901029062899913,0.22792051569325877
0.34912407842419996,0.22775701424790776
0.34906718452660207,0.22775701424790776
0.34901029062899913,0.22775701424790776
0.3486120333457989,0.22775701424790776
0.34827066996020156,0.22751176207985227
0.3478724126770013,0.22702125774379933
0.34781551877939837,0.22693950702109478
0.3476448370865997,0.22677600557574382
0.34724657980339946,0.22669425485309738
0.34679142862260137,0.22644900268504187
0.34656385303219983,0.22628550123969088
0.34656385303219983,0.22628550123969088
0.3465069591345969,0.22612199979433992
0.3463931713394012,0.22612199979433992
0.3462224896465974,0.22620375051698632
0.345938020158598,0.22661250413039283
0.3457104445681964,0.22677600557574382
0.3456535506705985,0.22669425485309738
0.34536908118259907,0.22628550123969088
0.34536908118259907,0.2260402490716354
0.3455397628753977,0.2257132461809334
0.34559665677300067,0.2254679940128779
0.34559665677300067,0.22538624329023146
0.345938020158598,0.22407823172736555
0.345938020158598,0.223996481004661
0.3461655957489996,0.22358772739131264
0.34627938354420035,0.2225249679964441
0.34627938354420035,0.22244321727379768
0.3465069591345969,0.22211621438309573
0.34684832252019926,0.22195271293774474
0.3471327920081987,0.22195271293774474
0.34730347370099734,0.22211621438309573
0.3473603675986003,0.22219796510574216
0.34753104929139894,0.22236146655109315
0.3478724126770013,0.2222797158284467
0.34804309436979997,0.22236146655109315
0.34855513944820105,0.22277022016449965
0.3486120333457989,0.22277022016449965
0.34957922960499804,0.22317897377790613
0.34974991129780175,0.22342422594596165
0.34974991129780175,0.22342422594596165
0.3502619563762028,0.22342422594596165
0.3509446831474025,0.2237512288366636
0.35100157704500035,0.2237512288366636
0.35185498550899874,0.2237512288366636
0.35185498550899874,0.22358772739131264
0.3515136221234014,0.22326072450055257
0.3514567282257985,0.22326072450055257
0.35122915263540194,0.2229337216098506
0.35117225873779906,0.2222797158284467
0.35111536484020117,0.2220344636603912
0.3510584709426033,0.22195271293774474
0.3508308953522017,0.22195271293774474
0.3507740014545988,0.22178921149239375
0.3507171075570009,0.2216257100469847
0.3507171075570009,0.2213804578789873
0.35054642586420226,0.22129870715628275
0.35054642586420226,0.22088995354287627
0.35054642586420226,0.2208082028202298
0.3507171075570009,0.22023594776147234
0.35066021365940303,0.21950019125736392
0.3506033197618001,0.21941844053465936
0.35066021365940303,0.218028678249147
0.35066021365940303,0.21794692752644249
0.3507171075570009,0.21753817391309407
0.3509446831474025,0.21729292174503856
0.3508877892497996,0.2167206666862811
0.3509446831474025,0.2167206666862811
0.35100157704500035,0.21663891596363463
0.35100157704500035,0.2160666609048772
0.3508877892497996,0.2156579072914707
0.35043263806900143,0.2156579072914707
0.35037574417139855,0.21557615656876614
0.35031885027380066,0.21557615656876614
0.35043263806900143,0.21541265512341518
0.3507740014545988,0.2152491536780642
0.3509446831474025,0.21516740295541775
0.35128604653299983,0.21516740295541775
0.3515136221234014,0.2150039015100668
0.3515136221234014,0.21492215078736224
0.3517980916114009,0.21467689861930675
0.35231013668980193,0.2144316464513093
0.3524239244850027,0.21418639428325378
0.35265150007539925,0.21402289283790285
0.35265150007539925,0.21402289283790285
0.35293596956340373,0.21361413922449635
0.35293596956340373,0.2133688870564989
0.3530497573585995,0.2129601334430924
0.3530497573585995,0.2127148812750369
0.3530497573585995,0.21255137982968594
0.35322043905140316,0.21214262621627944
0.353334226846604,0.21197912477092845
0.35350490853940264,0.21197912477092845
0.3536755902322013,0.21214262621627944
0.35361869633460347,0.21255137982968594
0.35361869633460347,0.21263313055239044
0.35361869633460347,0.2128783827203879
0.3537893780274021,0.2133688870564989
0.3540169536178037,0.21345063777914536
0.3542445292082002,0.21304188416573885
0.35430142310580315,0.21304188416573885
0.35452899869620474,0.2128783827203879
0.35509793767220366,0.2128783827203879
0.3552686193650023,0.21304188416573885
0.35566687664820257,0.2131236348884434
0.35572377054580046,0.2131236348884434
0.356463391214603,0.21345063777914536
0.35691854239540116,0.21361413922449635
0.3569754362930041,0.21361413922449635
0.3584546776306043,0.21418639428325378
0.35856846542580506,0.21418639428325378
0.3589667227090053,0.21418639428325378
0.359649449480205,0.2139411421151983
0.3598770250706066,0.21402289283790285
0.3599339189682045,0.21402289283790285
0.36055975184180633,0.21418639428325378
0.3612993725106039,0.2137776406698473
0.3613562664082068,0.21369588994720085
0.36175452369140704,0.21345063777914536
0.3619252053842057,0.21353238850184988
0.36260793215540543,0.21320538561108984
0.36266482605300326,0.21320538561108984
0.3627786138482041,0.2131236348884434
0.3627217199506062,0.2133688870564989
0.36289240164340486,0.2133688870564989
0.3630630833362035,0.2133688870564989
0.36340444672180594,0.21304188416573885
0.36363202231220754,0.21279663199774146
0.36368891620980537,0.21279663199774146
0.3649974758546069,0.21189737404828202
0.36505436975220473,0.2118156233255775
0.3652250514450085,0.21157037115752197
0.36539573313780715,0.21157037115752197
0.3656802026258066,0.21173387260287296
0.3659077782162082,0.2118156233255775
0.3662491416018055,0.2114886204348755
0.3663629293970063,0.2114886204348755
0.3664767171922071,0.21116161754417356
0.36693186837300523,0.2105893624854161
0.3669887622706081,0.2103441103173606
0.367045656168206,0.21026235959465606
0.367045656168206,0.21018060887200962
0.3669887622706081,0.21009885814930507
0.36664739888500575,0.20944485236790117
0.3665905049874079,0.20969010453595668
0.3667611866802066,0.2099353567039541
0.36681808057780946,0.21001710742665863
0.3669887622706081,0.21009885814930507
0.3672163378610097,0.21009885814930507
0.3681835341202089,0.20969010453595668
0.36829732191540965,0.20960835381325216
0.3690369425842072,0.20936310164519664
0.36915073037940804,0.20919960019984568
0.3693214120722067,0.20919960019984568
0.3695489876626083,0.20919960019984568
0.3696058815602062,0.20919960019984568
0.36989035104821066,0.20919960019984568
0.3700610327410093,0.2086273451410882
0.3701179266386072,0.20887259730914373
0.3704592900242095,0.20854559441844175
0.3705161839218074,0.20854559441844175
0.3708575473074098,0.20838209297309077
0.37091444120500766,0.20821859152773978
0.3708575473074098,0.20805509008238882
0.37108512289781137,0.20772808719162877
0.3713126984882079,0.20756458574627779
0.37142648628340874,0.20756458574627779
0.3720523191570106,0.20699233068752032
0.3724505764402108,0.20682882924216933
0.3725643642354116,0.20666532779681837
0.3726212581330095,0.20666532779681837
0.3723936825426079,0.20666532779681837
0.3722798947474071,0.2067470785195229
0.3729626215186118,0.20601132201541444
0.3730195154162097,0.20601132201541444
0.3732470910066113,0.20584782057006348
0.3734746665970078,0.20552081767930344
0.3734746665970078,0.20535731623395245
0.3737591360850123,0.2050303133432505
0.3738160299826102,0.204785061175195
0.37387292388020804,0.20470331045254855
0.3740436055730118,0.20429455683914205
0.3744987567538099,0.2038040525030891
0.3747263323442115,0.20339529888968264
0.3747832262418094,0.20339529888968264
0.37512458962741174,0.20315004672162712
0.3752952713202104,0.20282304383092517
0.37575042250100854,0.20241429021751867
0.3758073163986114,0.2023325394948722
0.3758642102962093,0.20192378588146576
0.37569352860341065,0.20151503226805925
0.37512458962741174,0.2016785337134102
0.37512458962741174,0.2016785337134102
0.3743849689586091,0.20176028443611477
0.37398671167540887,0.2016785337134102
0.3737591360850123,0.20176028443611477
0.3737022421874094,0.20176028443611477
0.3734746665970078,0.20192378588146576
0.3729626215186118,0.2018420351588193
0.37290572762100893,0.2016785337134102
0.3729626215186118,0.20151503226805925
0.3729626215186118,0.20135153082270826
0.3729626215186118,0.20126978010006183
0.3730195154162097,0.20086102648665533
0.37313330321141047,0.20045227287324888
0.3730764093138076,0.20004351925984237
0.3730764093138076,0.19979826709184492
0.37313330321141047,0.19971651636914042
0.3735884543922086,0.19849025552897903
0.3735884543922086,0.19840850480627448
0.3737022421874094,0.19816325263827708
0.3735884543922086,0.197999751192868
0.3740436055730118,0.19742749613416863
0.37410049947060964,0.1971822439661131
0.37410049947060964,0.1971004932434086
0.37444186285621206,0.19595598312595175
0.37455565065141283,0.19571073095789623
0.3746125445490107,0.19562898023519168
0.37518148352500963,0.19432096867238385
0.37523837742261257,0.19423921794967933
0.3752952713202104,0.19391221505897738
0.37546595301300906,0.19391221505897738
0.37575042250100854,0.19358521216827543
0.37609178588661085,0.19317645855486892
0.37614867978420874,0.1930947078321644
0.37631936147701245,0.1929312063868134
0.3764900431698111,0.19260420349611146
0.3764331492722133,0.191868446992003
0.3764900431698111,0.19178669626929848
0.37683140655541353,0.19154144410130106
0.37683140655541353,0.19105093976519003
0.3767745126578106,0.19056043542913711
0.3767745126578106,0.19047868470649065
0.37688830045301136,0.19006993109308418
0.37688830045301136,0.1897429282023822
0.37683140655541353,0.1894976760343267
0.37694519435060925,0.18900717169827377
0.37694519435060925,0.18892542097556925
0.37694519435060925,0.18827141519416532
0.37683140655541353,0.18810791374881433
0.37683140655541353,0.18794441230340528
0.37705898214581,0.18761740941270333
0.37711587604341296,0.18753565869005687
0.37745723942901027,0.18622764712719098
0.37745723942901027,0.18614589640448642
0.37768481501941187,0.18581889351378447
0.37768481501941187,0.18557364134572896
0.37774170891700976,0.18581889351378447
0.37774170891700976,0.18516488773238055
0.37774170891700976,0.18508313700967605
0.37796928450741135,0.1843473805055676
0.3780261784050143,0.18377512544681013
0.3780830723026121,0.18369337472416367
0.3783106478930137,0.18336637183340362
0.3788795868690126,0.1831211196654062
0.37922095025460995,0.18279411677470425
0.3793347380498107,0.18271236605199973
0.37944852584501154,0.1824671138839442
0.37996057092341257,0.1818948588252448
0.3801312526162112,0.18148610521183833
0.38018814651381416,0.18132260376648734
0.3807001915922152,0.18042334581702793
0.3807570854898131,0.18001459220362143
0.3807570854898131,0.17993284148091693
0.38092776718261173,0.17952408786751042
0.38092776718261173,0.17870658064075554
0.38092776718261173,0.178624829918051
0.3808708732850139,0.17854307919540455
0.38092776718261173,0.17837957775005356
0.381326024465812,0.17829782702734906
0.38149670615861564,0.17813432558199807
0.3818949634418159,0.17813432558199807
0.38166738785141435,0.17797082413664708
0.38166738785141435,0.17788907341394256
0.38115534277301333,0.1764993111284302
0.38115534277301333,0.17641756040572568
0.38098466108021467,0.17617230823772823
0.38109844887541544,0.17600880679237726
0.3808708732850139,0.17568180390161722
0.3808708732850139,0.17551830245626623
0.3808708732850139,0.17510954884291785
0.3808708732850139,0.17502779812021332
0.38092776718261173,0.1747825459521578
0.38143981226101276,0.17470079522951135
0.38115534277301333,0.17453729378416036
0.3808708732850139,0.17453729378416036
0.38081397938741096,0.17453729378416036
0.3804726160018136,0.17421029089340032
0.3804157221042107,0.17404678944804935
0.3805295098994115,0.17388328800269837
0.3807001915922152,0.1739650387254029
0.38104155497781256,0.1737197865573474
0.38104155497781256,0.17363803583470094
0.3812691305682141,0.17339278366664546
0.38138291836341487,0.17347453438934998
0.38149670615861564,0.1733110329439409
0.38138291836341487,0.1724117749944815
0.38138291836341487,0.1724117749944815
0.3812691305682141,0.17167601849037303
0.38115534277301333,0.17151251704502207
0.3812122366706112,0.17077676054091362
0.3812122366706112,0.17069500981826716
0.38115534277301333,0.17020450548215615
0.38138291836341487,0.1698775025914542
0.3815536000562135,0.16979575186880774
0.3817811756466151,0.1698775025914542
0.38200875123701167,0.16979575186880774
0.3820656451346146,0.16971400114610322
0.3822932207250162,0.16963225042345675
0.3827483719058143,0.1693052475326967
0.3827483719058143,0.16914174608734575
0.3828621597010151,0.16889649391934833
0.3830897352914116,0.16873299247393925
0.38320352308661243,0.16873299247393925
0.3833173108818132,0.1685694910285883
0.3835448864722148,0.16816073741523987
0.3836017803698127,0.16799723596983082
0.3836017803698127,0.1675884823564824
0.3836017803698127,0.16750673163377788
0.3836017803698127,0.1671797287430759
0.3837724620626164,0.16693447657502042
0.3836586742674156,0.16677097512966943
0.3837724620626164,0.16660747368431844
0.3838862498578121,0.16644397223896745
0.3838862498578121,0.16619872007091196
0.3838862498578121,0.16611696934826553
0.3841707193458166,0.16578996645756355
0.3841707193458166,0.1656264650121545
0.3840569315506158,0.16513596067610156
0.3837724620626164,0.16497245923075057
0.38371556816501345,0.16497245923075057
0.3836017803698127,0.16472720706269506
0.38371556816501345,0.16464545634004862
0.3835448864722148,0.16407320128129116
0.38348799257461186,0.16366444766788468
0.38348799257461186,0.16358269694523822
0.38337420477941614,0.1633374447771827
0.383431098677014,0.16309219260912722
0.3838862498578121,0.1626834389957207
0.38400003765301294,0.16227468538237233
0.38400003765301294,0.16219293465966778
0.38394314375541505,0.1617841810462613
0.3838293559602143,0.1616206796009103
0.3837724620626164,0.16113017526485737
0.3836017803698127,0.16080317237415542
0.3836017803698127,0.1607214216514509
0.38326041698421537,0.15998566514734244
0.3828621597010151,0.1596586622566405
0.3827483719058143,0.1596586622566405
0.3824070085202119,0.15933165936593852
0.38223632682741326,0.1584324014164791
0.38223632682741326,0.15835065069377458
0.3821794329298154,0.15761489418966612
0.3822932207250162,0.15696088840826222
0.38235011462261403,0.1568791376855577
0.38257769021301563,0.15679738696291123
0.3827483719058143,0.15679738696291123
0.38291905359861295,0.15696088840826222
0.38303284139381377,0.1573696420216687
0.38337420477941614,0.15761489418966612
0.383431098677014,0.15761489418966612
0.3836586742674156,0.1577783956350171
0.3838293559602143,0.15769664491237068
0.3838862498578121,0.1573696420216687
0.3838293559602143,0.15720614057625965
0.38371556816501345,0.15671563624020668
0.38371556816501345,0.15663388551756025
0.3836586742674156,0.15540762467734082
0.3838862498578121,0.15532587395469435
0.38394314375541505,0.15532587395469435
0.38485344611701633,0.15597987973609825
0.3849672339122171,0.15622513190415377
0.3849672339122171,0.1563068826268002
0.38513791560501576,0.15655213479485572
0.3852517034002166,0.15638863334950476
0.3853085972978144,0.15606163045880278
0.385536172888216,0.15606163045880278
0.3858775362738133,0.1563068826268002
0.38593443017141627,0.15638863334950476
0.38656026304501306,0.15704263913090866
0.38678783863541466,0.15745139274431513
0.38684473253301754,0.1575331434670197
0.3870723081234141,0.15810539852571906
0.3871860959186149,0.1584324014164791
0.3875274593042172,0.15875940430718105
0.3875843532018151,0.15875940430718105
0.3878119287922167,0.15892290575253204
0.3886653372562151,0.1596586622566405
0.388722231153818,0.1596586622566405
0.3895756396178164,0.16047616948345347
0.38968942741301715,0.16063967092880443
0.38974632131061504,0.1607214216514509
0.39025836638901606,0.16129367671020836
0.3904290480818147,0.1613754274329129
0.390827305365015,0.16129367671020836
0.39094109316021575,0.16129367671020836
0.39116866875061734,0.16129367671020836
0.39139624434101894,0.16145717815555932
0.3915669260338176,0.16145717815555932
0.3918513955218171,0.16129367671020836
0.39202207721461574,0.16129367671020836
0.39202207721461574,0.16113017526485737
0.39190828941942,0.16104842454215285
0.3918513955218171,0.16104842454215285
0.3914531382386168,0.1607214216514509
0.39116866875061734,0.16023091731539796
0.3909979870578187,0.16023091731539796
0.3907704114674171,0.16014916659269343
0.3907135175698192,0.16006741587004697
0.3905428358770155,0.15982216370199148
0.39059972977461843,0.15957691153393594
0.39031526028661895,0.1588411550298275
0.39031526028661895,0.15875940430718105
0.3901445785938153,0.15835065069377458
0.3900876846962174,0.1579418970803681
0.3901445785938153,0.1577783956350171
0.39037215418421684,0.1577783956350171
0.39059972977461843,0.1580236478030726
0.3906566236722163,0.1580236478030726
0.3909979870578187,0.15835065069377458
0.3915669260338176,0.1584324014164791
0.39196518331701785,0.15859590286183006
0.39202207721461574,0.15859590286183006
0.39270480398582047,0.15875940430718105
0.39276169788341836,0.15851415213912556
0.39224965280501733,0.15810539852571906
0.39224965280501733,0.15810539852571906
0.3920789711122187,0.15786014635772164
0.39219275890741945,0.15769664491237068
0.3923634406002181,0.15761489418966612
0.39270480398582047,0.1579418970803681
0.39315995516661856,0.15810539852571906
0.39321684906421644,0.15810539852571906
0.3932737429618194,0.15810539852571906
0.3933306368594172,0.1579418970803681
0.39315995516661856,0.15769664491237068
0.3931030612690207,0.1571243898536132
0.3933306368594172,0.1571243898536132
0.39338753075702015,0.15720614057625965
0.3938426819378183,0.1575331434670197
0.39395646973301907,0.1571243898536132
0.3938995758354161,0.15696088840826222
0.39395646973301907,0.15671563624020668
0.39395646973301907,0.15663388551756025
0.39401336363061695,0.15622513190415377
0.39395646973301907,0.15606163045880278
0.3936151063474167,0.15532587395469435
0.3936151063474167,0.15532587395469435
0.39321684906421644,0.1545901174505859
0.39287548567861913,0.15434486528253039
0.392932379576217,0.1541813638371794
0.392932379576217,0.1540996131144749
0.392932379576217,0.1541813638371794
0.39287548567861913,0.1540996131144749
0.3926479100882176,0.15385436094647748
0.39253412229301676,0.15344560733307094
0.39224965280501733,0.15287335227431348
0.39219275890741945,0.15287335227431348
0.3921358650098165,0.152464598660907
0.39202207721461574,0.15189234360214954
0.3917945016242192,0.15148358998880113
0.3917945016242192,0.1514018392660966
0.3915669260338176,0.15099308565269012
0.39151003213621977,0.15042083059393266
0.3916807138290184,0.15042083059393266
0.39196518331701785,0.15042083059393266
0.39202207721461574,0.15050258131663719
0.39276169788341836,0.1507478334846927
0.39281859178101625,0.15091133493004366
0.39281859178101625,0.15066608276198817
0.39270480398582047,0.15042083059393266
0.39270480398582047,0.15042083059393266
0.39247722839541893,0.14976682481252873
0.392420334497816,0.14960332336717774
0.3923634406002181,0.14919456975377127
0.3923065467026203,0.14894931758577384
0.39224965280501733,0.14894931758577384
0.3917945016242192,0.1482135610816654
0.3916807138290184,0.14764130602290793
0.3916807138290184,0.1475595553002034
0.3915669260338176,0.14739605385485244
0.3915669260338176,0.14723255240950145
0.39139624434101894,0.1469055495187995
0.39139624434101894,0.14674204807344854
0.3915669260338176,0.14657854662803946
0.39173760772661625,0.14682379879609497
0.39173760772661625,0.14682379879609497
0.392932379576217,0.14764130602290793
0.3929892734738199,0.1475595553002034
0.3930461673714178,0.1475595553002034
0.39281859178101625,0.14723255240950145
0.39276169788341836,0.14666029735074398
0.39287548567861913,0.14625154373733748
0.39281859178101625,0.14616979301469105
0.39276169788341836,0.14592454084663553
0.39270480398582047,0.14551578723322905
0.39281859178101625,0.14469828000647417
0.39281859178101625,0.1446165292837696
0.39281859178101625,0.14420777567036314
0.3929892734738199,0.14379902205701472
0.3933306368594172,0.14322676699825726
0.3933306368594172,0.14322676699825726
0.3935582124498188,0.14306326555290627
0.3936151063474167,0.14281801338485078
0.3937857880402204,0.14273626266214623
0.39418404532342066,0.14208225688074233
0.39424093922101855,0.14208225688074233
0.39446851481142015,0.14175525399004038
0.39446851481142015,0.14191875543539134
0.3942978331186164,0.1420005061580959
0.39424093922101855,0.1426545119394998
0.39418404532342066,0.14273626266214623
0.3943547270162193,0.1431450162755527
0.3944116209138172,0.1437172713343102
0.3946391965042188,0.1441260249477167
0.3946391965042188,0.14420777567036314
0.39469609040181663,0.14445302783841862
0.39480987819701746,0.1446165292837696
0.39498055988982117,0.14486178145182513
0.3951512415826198,0.1449435321744716
0.3953219232754185,0.1447800307291206
0.3953788171730214,0.14453477856112318
0.3953219232754185,0.14445302783841862
0.3951512415826198,0.14404427422501215
0.3952081354802177,0.14379902205701472
0.3953219232754185,0.14355376988895924
0.3954357110706193,0.1431450162755527
0.3954357110706193,0.14306326555290627
0.39572018055861874,0.14298151483020174
0.396118437841819,0.1424910104941488
0.39645980122742136,0.14298151483020174
0.39645980122742136,0.14298151483020174
0.39668737681781785,0.1431450162755527
0.39691495240821945,0.1431450162755527
0.39697184630581733,0.14355376988895924
0.39702874020342027,0.1437172713343102
0.39719942189621893,0.14388077277966116
0.3974269974866205,0.14388077277966116
0.3974838913842184,0.14388077277966116
0.3981666181554181,0.14404427422501215
0.39873555713142206,0.14404427422501215
0.3989062388242207,0.14428952639306766
0.3989062388242207,0.1443712771157722
0.3989631327218186,0.14445302783841862
0.39919070831222014,0.14445302783841862
0.39919070831222014,0.1447800307291206
0.3990200266194215,0.14486178145182513
0.39856487543861835,0.14453477856112318
0.39850798154102046,0.14445302783841862
0.3981097242578202,0.1441260249477167
0.39793904256502155,0.1441260249477167
0.39776836087221784,0.14420777567036314
0.3975407852818213,0.1441260249477167
0.3973132096914197,0.14379902205701472
0.39725631579382187,0.1437172713343102
0.39719942189621893,0.14347201916625468
0.39668737681781785,0.14322676699825726
0.39663048292022,0.14306326555290627
0.39668737681781785,0.14281801338485078
0.39663048292022,0.14257276121679527
0.39657358902262213,0.1424910104941488
0.3964029073298184,0.14232750904879785
0.3964029073298184,0.14191875543539134
0.39634601343222053,0.14175525399004038
0.39645980122742136,0.1411012482085784
0.39645980122742136,0.1411012482085784
0.3965166951250192,0.14085599604058097
0.39645980122742136,0.14044724242717446
0.39657358902262213,0.1401202395364725
0.39691495240821945,0.13979323664577056
0.39691495240821945,0.13979323664577056
0.39719942189621893,0.13938448303236406
0.3976545730770221,0.13897572941895758
0.3976545730770221,0.13873047725090204
0.3975407852818213,0.13848522508290464
0.3975407852818213,0.13840347436020012
0.3973701035890176,0.13840347436020012
0.3970856341010181,0.13889397869625303
0.39657358902262213,0.13922098158701307
0.39645980122742136,0.13922098158701307
0.39572018055861874,0.1393027323096595
0.3950943476850169,0.13938448303236406
0.39503745378741906,0.1394662337550105
0.3945823026066209,0.139874987368417
0.3943547270162193,0.13954798447771505
0.3942978331186164,0.1393027323096595
0.3942978331186164,0.1390574801416621
0.3942978331186164,0.1390574801416621
0.3943547270162193,0.1386487265282556
0.39424093922101855,0.13848522508290464
0.39424093922101855,0.13832172363755368
0.394525408709018,0.1377494685787962
0.394525408709018,0.1377494685787962
0.39498055988982117,0.1377494685787962
0.39526502937782065,0.1373407149653897
0.3954357110706193,0.1369319613519832
0.3954357110706193,0.1369319613519832
0.3957770744562216,0.13660495846128126
0.3957770744562216,0.13635970629322575
0.39572018055861874,0.13578745123446828
0.3959477561490203,0.1356239497891173
0.3959477561490203,0.1356239497891173
0.39623222563701976,0.1352151961757689
0.39634601343222053,0.1348064425623624
0.3968011646130187,0.13439768894895593
0.3968011646130187,0.13439768894895593
0.39719942189621893,0.13374368316755203
0.3974269974866205,0.13317142810879457
0.3974269974866205,0.13308967738609
0.3975407852818213,0.13292617594073902
0.3974269974866205,0.1326809237726835
0.39776836087221784,0.13219041943663057
0.39771146697461995,0.13169991510057766
0.39776836087221784,0.1316181643778731
0.39799593646261944,0.1311276600418202
0.3981666181554181,0.13039190353771174
0.39822351205302103,0.13031015281500719
0.39822351205302103,0.1300649006470098
0.3981666181554181,0.12916564269755038
0.39799593646261944,0.1290021412521994
0.39793904256502155,0.12892039052949483
0.3976545730770221,0.12802113258003545
0.3974269974866205,0.12769412968933347
0.3973701035890176,0.12769412968933347
0.39691495240821945,0.12753062824398248
0.3968580585106216,0.12736712679857343
0.39657358902262213,0.12663137029446497
0.39657358902262213,0.12654961957181854
0.39645980122742136,0.12630436740376302
0.39634601343222053,0.12614086595841206
0.39623222563701976,0.12556861089965457
0.39628911953461765,0.12532335873165715
0.39628911953461765,0.12524160800895262
0.39634601343222053,0.1245876022275487
0.39628911953461765,0.12417884861414222
0.39634601343222053,0.1239335964460867
0.39634601343222053,0.1239335964460867
0.39657358902262213,0.12319783994197826
0.39691495240821945,0.12270733560592534
0.39697184630581733,0.1226255848832208
0.39714252799862104,0.12238033271522336
0.39776836087221784,0.12164457621111494
0.3978252547698208,0.1215628254884104
0.3981097242578202,0.12123582259770845
0.3982804059506189,0.12099057042965293
0.39856487543861835,0.12082706898430196
0.39873555713142206,0.12058181681630452
0.39879245102901995,0.12050006609359999
0.3989062388242207,0.12033656464824902
0.39919070831222014,0.11968255886678701
0.39941828390262174,0.11935555597608506
0.39941828390262174,0.11927380525343861
0.3995889655954204,0.1190285530853831
0.3995320716978225,0.11927380525343861
0.3993613900050188,0.11911030380808764
0.39941828390262174,0.11894680236267857
0.39919070831222014,0.11861979947197661
0.39913381441462226,0.11861979947197661
0.39884934492662283,0.11829279658127466
0.39862176933622123,0.11829279658127466
0.39822351205302103,0.11804754441321914
0.3980528303602224,0.11821104585857013
0.3981097242578202,0.11845629802662563
0.3981097242578202,0.11845629802662563
0.39799593646261944,0.11829279658127466
0.3976545730770221,0.11804754441321914
0.3973701035890176,0.1177205415225172
0.39719942189621893,0.1177205415225172
0.39702874020342027,0.11747528935451976
0.39697184630581733,0.11747528935451976
0.39691495240821945,0.11755704007716622
0.39691495240821945,0.11698478501840874
0.39668737681781785,0.11665778212770678
0.3965166951250192,0.11673953285041132
0.3965166951250192,0.11698478501840874
0.39645980122742136,0.11706653574111328
0.3958908622514174,0.11739353863181523
0.3957770744562216,0.11731178790911069
0.3958908622514174,0.11698478501840874
0.3958908622514174,0.11673953285041132
0.3958908622514174,0.11665778212770678
0.3958339683538195,0.1162490285143003
0.3958908622514174,0.11608552706894933
0.3958339683538195,0.11584027490089381
0.3957770744562216,0.11526801984219442
0.3958339683538195,0.1151862691194899
0.3961753317394219,0.11445051261538144
0.3958339683538195,0.11428701117003047
0.39566328666102085,0.11445051261538144
0.39566328666102085,0.11445051261538144
0.39572018055861874,0.11428701117003047
0.39566328666102085,0.11404175900197497
0.39526502937782065,0.1141235097246795
0.3952081354802177,0.11387825755662398
0.3953788171730214,0.11379650683397755
0.39549260496821714,0.11355125466592203
0.39549260496821714,0.11355125466592203
0.39572018055861874,0.11330600249786651
0.39572018055861874,0.11306075032986909
0.3960615439442211,0.11232499382576065
0.3960615439442211,0.11232499382576065
0.39645980122742136,0.11158923732165221
0.39657358902262213,0.11093523154019021
0.39657358902262213,0.11085348081754376
0.39663048292022,0.1102812257587863
0.39657358902262213,0.11011772431343532
0.3965166951250192,0.10954546925467784
0.3965166951250192,0.10946371853197331
0.39645980122742136,0.10913671564127135
0.3965166951250192,0.10864621130521843
0.39657358902262213,0.1081557069691074
0.3965166951250192,0.10807395624646095
0.3965166951250192,0.10791045480110997
0.39657358902262213,0.10750170118770348
0.39634601343222053,0.10717469829700153
0.39645980122742136,0.10676594468359504
0.3965166951250192,0.1066841939608905
0.3967442707154208,0.10635719107018855
0.3970856341010181,0.10619368962483756
0.3973132096914197,0.10553968384343367
0.3973132096914197,0.10553968384343367
0.3976545730770221,0.1049674287846762
0.3976545730770221,0.10423167228056776
0.3976545730770221,0.10423167228056776
0.3976545730770221,0.10365941722181028
0.39776836087221784,0.10382291866716126
0.39771146697461995,0.10325066360840379
0.39771146697461995,0.10316891288575734
0.3976545730770221,0.10276015927235085
0.39771146697461995,0.10259665782699989
0.39793904256502155,0.10251490710429535
0.3981097242578202,0.10186090132289144
0.3981097242578202,0.10177915060018691
0.3981097242578202,0.1013703969868385
0.3983372998482218,0.10145214770948494
0.3983941937458197,0.10120689554142943
0.39879245102901995,0.1007163912053765
0.39879245102901995,0.1007163912053765
0.3981666181554181,0.10063464048273005
0.3982804059506189,0.100471139037321
0.39850798154102046,0.100471139037321
0.39850798154102046,0.10030763759197
0.39873555713142206,0.10030763759197
0.39873555713142206,0.10038938831467455
0.3989631327218186,0.10030763759197
0.3990200266194215,0.10014413614661903
0.39913381441462226,0.09998063470126806
0.39919070831222014,0.09957188108786157
0.3994751778002196,0.0992448781971596
0.3994751778002196,0.0992448781971596
0.39964585949302334,0.09908137675180864
0.39964585949302334,0.09875437386110666
0.3998734350834199,0.09875437386110666
0.4000441167762236,0.09891787530645765
0.40021479846902225,0.09883612458375313
0.4003285862642231,0.0985908724157557
0.4003285862642231,0.0985908724157557
0.40049926795702173,0.09826386952499566
0.40084063134261905,0.09777336518894272
0.4012388886258193,0.0975281130209453
0.4012957825234222,0.0975281130209453
0.40135267642102007,0.09744636229824077
0.401409570318623,0.09728286085288979
0.4012388886258193,0.09720111013018526
0.4012388886258193,0.09695585796218784
0.40135267642102007,0.09679235651683685
0.40169403980662244,0.0966288550714278
0.40192161539702403,0.09671060579413232
0.40197850929462187,0.09671060579413232
0.40220608488502346,0.09654710434878135
0.4023198726802243,0.09638360290343036
0.4023198726802243,0.09605660001272841
0.40249055437302295,0.0954025942312664
0.40249055437302295,0.09532084350861997
0.4025474482706208,0.09507559134056445
0.4027750238610224,0.09474858844986249
0.4027750238610224,0.09442158555910245
0.40300259945142397,0.09409458266840048
0.40305949334902186,0.09409458266840048
0.40328706893942345,0.09393108122304952
0.40351464452982505,0.09344057688699658
0.40351464452982505,0.0932770754416456
0.4036853262226237,0.09311357399629462
0.40385600791542237,0.09295007255094365
0.4039129018130202,0.09286832182823912
0.4042542651986226,0.09245956821483262
0.4050507797650231,0.09213256532413067
0.4051076736626209,0.09205081460142614
0.40539214315062544,0.09196906387877968
0.4065869150002262,0.09213256532413067
0.4066438088978241,0.09221431604683519
0.4069282783858235,0.09221431604683519
0.4072696417714259,0.09213256532413067
0.4072696417714259,0.09188731315607515
0.40738342956662166,0.09172381171072418
0.40772479295222397,0.0915603102653732
0.4078385807474248,0.0915603102653732
0.40829373192822294,0.09131505809731769
0.4083506258258258,0.0911515566519667
0.4086919892114232,0.09090630448396929
0.40880577700662396,0.09074280303861831
0.4088626709042269,0.09066105231591379
0.4094316098802258,0.09041580014785826
0.4097160793682253,0.09008879725715631
0.4100574427538276,0.08976179436645436
0.4101143366514255,0.08976179436645436
0.4107970634226252,0.0894347914757524
0.4112522146034233,0.08902603786234592
0.4112522146034233,0.08894428713964138
0.41165047188662357,0.08853553352629298
0.412503880350627,0.08837203208094199
0.41256077424822485,0.08829028135823747
0.41278834983862644,0.08820853063553294
0.4132435010194246,0.08820853063553294
0.41335728881462536,0.08804502919018195
0.41352797050742907,0.0879632784675355
0.4136986522002277,0.08804502919018195
0.4138693338930264,0.08788152774483098
0.4138693338930264,0.08788152774483098
0.4136986522002277,0.08771802629948
0.4138124399954285,0.08755452485412901
0.41375554609782556,0.08739102340877804
0.4144382728690253,0.08698226979537155
0.4144382728690253,0.08698226979537155
0.41432448507382447,0.08673701762731603
0.41449516676662823,0.0866552669046696
0.4148934240498285,0.0866552669046696
0.4154054691282295,0.08632826401396765
0.4154623630258274,0.08632826401396765
0.4159744081042284,0.08600126112326567
0.416714028773026,0.08583775967785662
0.4167709226706289,0.08583775967785662
0.41716917995382913,0.0855925075098592
0.4177381189298281,0.08493850172839719
0.4180225884178275,0.08485675100575074
0.41807948231543046,0.08485675100575074
0.4189328907794288,0.08485675100575074
0.41910357247222746,0.08477500028304621
0.4193880419602269,0.08436624666963971
0.4194449358578298,0.08436624666963971
0.42001387483382874,0.0841209945016423
0.42035523821943116,0.08395749305629133
0.42081038940022925,0.08403924377893776
0.4208672832978322,0.08403924377893776
0.42137932837622816,0.0838757423335868
0.4217206917618305,0.08354873944288482
0.4221189490450308,0.0834669887201803
0.42228963073782944,0.08330348727482932
0.42228963073782944,0.08330348727482932
0.4224603124306281,0.08313998582947835
0.42285856971382835,0.08305823510683188
0.42302925140663206,0.08297648438412736
0.42365508428022886,0.08281298293877638
0.42359819038263097,0.08281298293877638
0.4240533415634291,0.08273123221607186
0.4242240232562328,0.08232247860272346
0.4243947049490315,0.08224072788001892
0.4247929622322317,0.08215897715731438
0.4247929622322317,0.08215897715731438
0.4253619012082306,0.08199547571196342
0.42530500731063275,0.081750223543966
0.425703264593833,0.08134146993055949
0.425703264593833,0.08125971920785496
0.42587394628663167,0.08134146993055949
0.4260446279794303,0.08142322065326403
0.4264428852626306,0.08134146993055949
0.42706871813623243,0.08134146993055949
0.4271825059314332,0.08134146993055949
0.42860485337143045,0.08117796848520853
0.42860485337143045,0.08109621776250399
0.428889322859435,0.08109621776250399
0.4287755350642342,0.08125971920785496
0.42934447404023307,0.08109621776250399
0.4292306862450323,0.08093271631715301
0.42900311065463076,0.08085096559450657
0.42900311065463076,0.08076921487180203
0.4284341716786318,0.08076921487180203
0.4277514449074321,0.08044221198110008
0.42763765711223134,0.08044221198110008
0.4272393998290311,0.08036046125839555
0.4276945510098342,0.08019695981304456
0.4280928082930345,0.0802787105357491
0.42814970219063236,0.08036046125839555
0.42814970219063236,0.08052396270374652
0.428320383883431,0.08060571342645105
0.4288324289618321,0.08068746414915559
0.4287186411666312,0.08052396270374652
0.42894621675703287,0.08052396270374652
0.42900311065463076,0.08060571342645105
0.4292306862450323,0.08076921487180203
0.4296289435282326,0.08076921487180203
0.4299703069138349,0.08093271631715301
0.4300272008114328,0.08076921487180203
0.43019788250423147,0.08068746414915559
0.43019788250423147,0.08068746414915559
0.43042545809463306,0.08060571342645105
0.4309943970706319,0.08085096559450657
0.4311650787634357,0.08068746414915559
0.4311650787634357,0.0802787105357491
0.4311650787634357,0.0802787105357491
0.43173401773943454,0.08011520909039813
0.4319615933298362,0.08003345836769359
0.43207538112503185,0.08019695981304456
0.4319615933298362,0.08036046125839555
0.4319615933298362,0.08052396270374652
0.4320184872274341,0.08060571342645105
0.43213227502263485,0.08060571342645105
0.4323029567154335,0.08076921487180203
0.4332132590770348,0.08085096559450657
0.4334408346674364,0.08101446703985754
0.4334977285650342,0.08101446703985754
0.4337821980530337,0.08109621776250399
0.4340666675410331,0.08134146993055949
0.4345218187218363,0.08199547571196342
0.4345218187218363,0.08199547571196342
0.4346356065170371,0.08215897715731438
0.43480628820983575,0.08232247860272346
0.4350338638002374,0.08264948149342541
0.43554590887863337,0.08305823510683188
0.43560280277623625,0.08313998582947835
0.43594416616183357,0.08363049016553128
0.43628552954743594,0.0838757423335868
0.43656999903543536,0.08420274522428875
0.43662689293303836,0.08420274522428875
0.436797574625837,0.08436624666963971
0.43702515021623856,0.08436624666963971
0.43713893801143433,0.08452974811504879
0.4372527258066351,0.08502025245110173
0.43736651360183587,0.08526550461915723
0.43736651360183587,0.08534725534180368
0.4375940891922375,0.08534725534180368
0.4381630281682364,0.08485675100575074
0.43833370986103504,0.08493850172839719
0.43839060375863803,0.08518375389645269
0.43839060375863803,0.08518375389645269
0.4385043915538388,0.08567425823250563
0.4384474976562359,0.08583775967785662
0.4385043915538388,0.08624651329126311
0.43839060375863803,0.0866552669046696
0.43839060375863803,0.08673701762731603
0.4384474976562359,0.08698226979537155
0.43878886104183823,0.08706402051807607
0.43913022442743554,0.08681876835002057
0.43930090612023925,0.08690051907272511
0.4394715878130379,0.0866552669046696
0.4394715878130379,0.08657351618196506
0.43964226950583657,0.08641001473661408
0.4398698450962382,0.08641001473661408
0.4398129511986352,0.08714577124072254
0.4398698450962382,0.08739102340877804
0.4398698450962382,0.08739102340877804
0.4398698450962382,0.08763627557683355
0.439926738993836,0.08779977702218453
0.439983632891439,0.08812677991288648
0.4403249962770363,0.08861728424893942
0.4403818901746392,0.08869903497164394
0.44066635966263873,0.08869903497164394
0.4404387840722371,0.08820853063553294
0.4404387840722371,0.0879632784675355
0.44055257186743785,0.08755452485412901
0.44055257186743785,0.08755452485412901
0.4408370413554374,0.08690051907272511
0.4408370413554374,0.08649176545931861
0.4407801474578395,0.08608301184591213
0.4407801474578395,0.08608301184591213
0.4408370413554374,0.08591951040056114
0.4410646169458389,0.08583775967785662
0.44095082915063816,0.08575600895521017
0.44100772304823604,0.08551075678715467
0.4411215108434368,0.08518375389645269
0.4412921925362405,0.08493850172839719
0.4412921925362405,0.08493850172839719
0.441519768126637,0.08452974811504879
0.4411784047410397,0.08379399161094034
0.4411784047410397,0.08354873944288482
0.4411215108434368,0.0834669887201803
0.44095082915063816,0.08313998582947835
0.44095082915063816,0.08297648438412736
0.4410646169458389,0.08281298293877638
0.4411784047410397,0.08215897715731438
0.4412352986386376,0.08215897715731438
0.441519768126637,0.081586722098615
0.44157666202423995,0.08101446703985754
0.441519768126637,0.08076921487180203
0.441519768126637,0.08076921487180203
0.4417473437170386,0.08068746414915559
0.4419180254098373,0.08125971920785496
0.44214560100023886,0.08166847282126145
0.44214560100023886,0.08191372498931696
0.44214560100023886,0.08199547571196342
0.4422593887954397,0.0824042293253699
0.44288522166903643,0.08313998582947835
0.4429421155666393,0.08322173655218287
0.44322658505463886,0.08354873944288482
0.4434541606450404,0.0834669887201803
0.4435110545426383,0.08371224088823581
0.4433403728498396,0.08379399161094034
0.44322658505463886,0.08395749305629133
0.44316969115704097,0.08420274522428875
0.44322658505463886,0.08420274522428875
0.44362484233783905,0.08477500028304621
0.4437386301330398,0.08469324956039978
0.4437386301330398,0.08420274522428875
0.4437386301330398,0.0841209945016423
0.4437955240306377,0.08354873944288482
0.44368173623543694,0.08273123221607186
0.44368173623543694,0.08264948149342541
0.44362484233783905,0.08166847282126145
0.44368173623543694,0.08150497137591048
0.4439093118258385,0.081586722098615
0.4439662057234415,0.08166847282126145
0.4445920385970382,0.0824042293253699
0.44476272028984193,0.08289473366142283
0.44476272028984193,0.08297648438412736
0.4449334019826406,0.08338523799753386
0.44504718977784136,0.0834669887201803
0.44510408367543924,0.08363049016553128
0.44521787147064,0.08379399161094034
0.4451609775730421,0.08420274522428875
0.44521787147064,0.08428449594699328
0.4452747653682379,0.08452974811504879
0.4451609775730421,0.08469324956039978
0.4451609775730421,0.08485675100575074
0.4452747653682379,0.0855925075098592
0.4452747653682379,0.08567425823250563
0.4453316592658408,0.08649176545931861
0.44538855316343867,0.08673701762731603
0.44544544706104167,0.08690051907272511
0.4456161287538403,0.08706402051807607
0.4456730226514382,0.08698226979537155
0.4456730226514382,0.08690051907272511
0.4456730226514382,0.08641001473661408
0.4460143860370405,0.08575600895521017
0.4460712799346384,0.08551075678715467
0.4461281738322413,0.0854290060645082
0.44641264332024083,0.08510200317374816
0.4466402189106424,0.08502025245110173
0.4469815822962397,0.08444799739234425
0.4469815822962397,0.08436624666963971
0.4469815822962397,0.0841209945016423
0.4470384761938426,0.08395749305629133
0.4470384761938426,0.0834669887201803
0.4469815822962397,0.08322173655218287
0.4470384761938426,0.08297648438412736
0.4470384761938426,0.08297648438412736
0.4472660517842392,0.08264948149342541
0.4473229456818421,0.08232247860272346
0.44755052127223865,0.081750223543966
0.44760741516984154,0.08166847282126145
0.44755052127223865,0.08109621776250399
0.44692468839864186,0.08060571342645105
0.4468677945010389,0.08052396270374652
0.4466402189106424,0.0802787105357491
0.4464695372178387,0.07986995692234261
0.4465264311154416,0.07970645547699162
0.4464695372178387,0.07954295403164066
0.4465264311154416,0.07905244969552963
0.4465833250130395,0.07897069897288318
0.4466402189106424,0.07880719752753221
0.44669711280824026,0.07839844391412572
0.44681090060344103,0.07823494246877474
0.44692468839864186,0.07766268741001728
0.44692468839864186,0.07758093668737082
0.4470384761938426,0.07717218307396434
0.4469815822962397,0.07692693090590882
0.44720915788664134,0.07692693090590882
0.44755052127223865,0.07643642656985589
0.44760741516984154,0.07635467584715136
0.447891884657841,0.07586417151109842
0.4482901419410412,0.07545541789769195
0.448403929736242,0.07529191645234096
0.4484608236338399,0.07521016572963643
0.44868839922424153,0.07463791067093704
0.4488590809170402,0.0739021541668286
0.4488590809170402,0.07382040344412406
0.44897286871224096,0.07275764404931367
0.44908665650744173,0.07234889043590717
0.4491435504050396,0.07226713971326074
0.4492004443026425,0.07210363826785166
0.44902976260983885,0.07202188754520522
0.44902976260983885,0.07267589332660913
0.44908665650744173,0.07308464694001562
0.44908665650744173,0.07316639766272015
0.4491435504050396,0.07357515127606856
0.44902976260983885,0.07414740633482603
0.44902976260983885,0.0745561599482325
0.44902976260983885,0.0745561599482325
0.4492573382002404,0.07463791067093704
0.4493142320978434,0.07529191645234096
0.4492573382002404,0.07545541789769195
0.44942801989303904,0.07570067006574745
0.44942801989303904,0.07570067006574745
0.4496555954834407,0.07570067006574745
0.44988317107384224,0.07651817729250233
0.45011074666424383,0.07643642656985589
0.45022453445943955,0.07668167873785331
0.4502814283570425,0.07676342946055784
0.45045211004984115,0.07692693090590882
0.4506227917426398,0.07741743524196176
0.4509641551282421,0.07798969030071923
0.45102104902584006,0.07798969030071923
0.45119173071864377,0.07823494246877474
0.4513624124114424,0.07880719752753221
0.45170377579704485,0.07937945258628967
0.4517606696946426,0.07946120330893612
0.4523296086706416,0.08036046125839555
0.45244339646584236,0.08076921487180203
0.45244339646584236,0.08085096559450657
0.45250029036344025,0.08093271631715301
0.45289854764664045,0.08101446703985754
0.4530692293394442,0.08117796848520853
0.453183017134645,0.08134146993055949
0.45329680492984076,0.08199547571196342
0.45335369882744364,0.08199547571196342
0.45341059272504153,0.08248598004807443
0.4535243805202423,0.08281298293877638
0.4535243805202423,0.0834669887201803
0.45358127441784524,0.0834669887201803
0.4536381683154431,0.08371224088823581
0.4537519561106439,0.08428449594699328
0.4536381683154431,0.08444799739234425
0.4537519561106439,0.08493850172839719
0.4538088500082418,0.08493850172839719
0.45386574390584467,0.08518375389645269
0.4537519561106439,0.08551075678715467
0.4537519561106439,0.08567425823250563
0.4540933194962412,0.08616476256861666
0.4541502133938441,0.08616476256861666
0.45392263780344255,0.08616476256861666
0.45392263780344255,0.08632826401396765
0.454207107291442,0.08657351618196506
0.454207107291442,0.08690051907272511
0.45437778898424575,0.08714577124072254
0.45437778898424575,0.08714577124072254
0.45432089508664275,0.08739102340877804
0.4546622584722452,0.08755452485412901
0.45477604626744594,0.0879632784675355
0.45483294016504383,0.08812677991288648
0.45471915236984306,0.08829028135823747
0.45477604626744594,0.08837203208094199
0.45477604626744594,0.08837203208094199
0.4549467279602446,0.08918953930769688
0.4550036218578425,0.08935304075304787
0.45483294016504383,0.08976179436645436
0.45483294016504383,0.0898435450891589
0.45477604626744594,0.08992529581180533
0.4549467279602446,0.08992529581180533
0.4550036218578425,0.09033404942521181
0.45517430355064625,0.09057930159320925
0.45523119744824414,0.09106980592932026
0.45523119744824414,0.09106980592932026
0.4554018791410428,0.09090630448396929
0.4554018791410428,0.09074280303861831
0.45551566693624357,0.09106980592932026
0.45551566693624357,0.09139680882002223
0.4557432425266451,0.09139680882002223
0.455800136424243,0.09139680882002223
0.45585703032184594,0.09139680882002223
0.45562945473144434,0.09139680882002223
0.455800136424243,0.09180556243342872
0.4559139242194438,0.09196906387877968
0.4560846059122425,0.09229606676948166
0.4560846059122425,0.09245956821483262
0.4560277120146446,0.09254131893753716
0.45597081811704165,0.09278657110553458
0.4560846059122425,0.09295007255094365
0.45614149980984536,0.09311357399629462
0.4562552876050462,0.0930318232735901
0.45648286319544273,0.09319532471894107
0.45642596929784485,0.09352232760964302
0.45642596929784485,0.09360407833234756
0.45642596929784485,0.09393108122304952
0.4560846059122425,0.09417633339110502
0.4562552876050462,0.09409458266840048
0.45642596929784485,0.09417633339110502
0.45642596929784485,0.09458508700451151
0.45648286319544273,0.09466683772715795
0.4565966509906435,0.09499384061785993
0.4565966509906435,0.09532084350861997
0.45676733268344216,0.09564784639932192
0.4568242265810451,0.09597484929002387
0.4568242265810451,0.09605660001272841
0.456881120478643,0.09630185218072582
0.45705180217144664,0.0964653536260768
0.45705180217144664,0.0966288550714278
0.45745005945464695,0.09679235651683685
0.4576776350450435,0.09720111013018526
0.4576776350450435,0.09728286085288979
0.4577345289426464,0.0975281130209453
0.45790521063544504,0.09769161446629628
0.4576776350450435,0.09777336518894272
0.4576207411474456,0.09793686663429368
0.4580758923282437,0.09834562024770019
0.4581327862258467,0.09842737097040472
0.45830346791864535,0.09908137675180864
0.45870172520184554,0.09981713325591707
0.45875861909944343,0.09981713325591707
0.45892930079224714,0.10006238542397258
0.45870172520184554,0.09998063470126806
0.458986194689845,0.10022588686932357
0.4593844519730452,0.1007163912053765
0.4594413458706431,0.10079814192808104
0.45966892146104465,0.1013703969868385
0.46006717874424496,0.10194265204553787
0.46012407264184785,0.10194265204553787
0.4602378604370436,0.10235140565894436
0.4602378604370436,0.10276015927235085
0.4604085421298473,0.1028419099949973
0.46052232992504816,0.10325066360840379
0.46052232992504816,0.10325066360840379
0.46057922382264593,0.10300541144040637
0.4606361177202438,0.1028419099949973
0.4606361177202438,0.10259665782699989
0.4607499055154446,0.1024331563816489
0.461091268901047,0.1030871621630528
0.461091268901047,0.10316891288575734
0.4611481627986449,0.10341416505375478
0.461091268901047,0.10398642011251225
0.46097748110584624,0.10365941722181028
0.46097748110584624,0.10390466938986578
0.46097748110584624,0.10390466938986578
0.4612050566962478,0.10406817083521677
0.46126195059384567,0.10382291866716126
0.4616033139794481,0.10365941722181028
0.46171710177464886,0.10374116794451482
0.461660207877046,0.10414992155786322
0.461660207877046,0.10423167228056776
0.46171710177464886,0.10472217661662069
0.46183088956984464,0.10488567806197166
0.4618877834674475,0.10570318528878464
0.4618877834674475,0.10578493601143109
0.4620584651602462,0.1064389417928931
0.4622860407506478,0.107092947574297
0.4622860407506478,0.10717469829700153
0.4622860407506478,0.10741995046499896
0.4621153590578491,0.10741995046499896
0.4620584651602462,0.10782870407840545
0.46222914685304484,0.1081557069691074
0.46245672244344643,0.10807395624646095
0.46245672244344643,0.10807395624646095
0.46279808582904874,0.10799220552375642
0.4630825553170483,0.10799220552375642
0.46365149429304714,0.10766520263305446
0.4637652820882479,0.10766520263305446
0.46410664547384534,0.10766520263305446
0.46444800885944765,0.10782870407840545
0.46450490275704553,0.10807395624646095
0.46450490275704553,0.10823745769181194
0.46439111496184976,0.10848270985986744
0.4643342210642469,0.10856446058251389
0.464277327166649,0.10889146347321585
0.4643342210642469,0.1090549649186249
0.46496005393784867,0.10946371853197331
0.46507384173304944,0.10954546925467784
0.4654152051186468,0.11003597359073078
0.465358311221049,0.11044472720413727
0.4655858868114505,0.11085348081754376
0.4655858868114505,0.11085348081754376
0.4657565685042492,0.11109873298554118
0.4660979318898465,0.11068997937219277
0.4664392952754489,0.11060822864948824
0.4666099769682476,0.11052647792678372
0.46666687086585046,0.11044472720413727
0.46672376476344835,0.11036297648143273
0.46695134035385,0.11077173009483923
0.4673495976370502,0.11077173009483923
0.4676909610226475,0.11077173009483923
0.4678047488178483,0.11060822864948824
0.4678047488178483,0.11060822864948824
0.4679185366130491,0.11068997937219277
0.46774785492025045,0.11085348081754376
0.46757717322744674,0.11077173009483923
0.4683736877938472,0.11085348081754376
0.4683736877938472,0.11093523154019021
0.46860126338424873,0.11109873298554118
0.46848747558904796,0.11118048370824571
0.46945467184825224,0.11134398515359668
0.4695115657458501,0.11142573587630122
0.4700236108242511,0.11150748659894767
0.4703649742098484,0.11167098804429865
0.47099080708345026,0.11158923732165221
0.47099080708345026,0.11158923732165221
0.4719011094450516,0.1119979909350006
0.4722993667282518,0.1119979909350006
0.4723562606258497,0.1119979909350006
0.4725838362162512,0.11207974165770514
0.4734372446802496,0.11281549816181358
0.47349413857785255,0.11289724888451812
0.4741768653490523,0.113714756111273
0.47429065314425306,0.11396000827932852
0.47423375924665007,0.11396000827932852
0.47423375924665007,0.11404175900197497
0.47429065314425306,0.11420526044732594
0.4746320165298504,0.11436876189267692
0.47508716771065357,0.11469576478343696
0.47520095550585434,0.11494101695143438
0.47520095550585434,0.11502276767413892
0.47548542499385377,0.11616727779165385
0.4754285310962509,0.11641252995965128
0.4754285310962509,0.1164942806823558
0.4756561066866524,0.11731178790911069
0.4757130005842503,0.1179657936905727
0.47576989448185325,0.11804754441321914
0.4758267883794511,0.11878330091732758
0.475371637198653,0.11935555597608506
0.4753147433010501,0.11943730669878959
0.47514406160825146,0.12025481392554448
0.4753147433010501,0.12082706898430196
0.4753147433010501,0.1209088197070065
0.47554231889145165,0.12107232115235747
0.475883682277054,0.12066356753895097
0.47605436396985273,0.12058181681630452
0.47645262125305293,0.12066356753895097
0.47656640904825376,0.12066356753895097
0.47696466633145396,0.12082706898430196
0.47736292361465427,0.12172632693376137
0.47741981751225204,0.12172632693376137
0.477988756488251,0.12238033271522336
0.4785008015666521,0.12270733560592534
0.47861458936185286,0.12270733560592534
0.479069740542656,0.12278908632857177
0.4794111039282533,0.12303433849662729
0.47969557341625274,0.1232795906646828
0.47975246731385573,0.12344309211003378
0.47969557341625274,0.12352484283268021
0.47963867951865485,0.12360659355538475
0.4795248917234541,0.12385184572344025
0.47969557341625274,0.12385184572344025
0.4803214062898546,0.12344309211003378
0.480605875777854,0.12336134138732924
0.4807196635730549,0.12336134138732924
0.4810610269586522,0.12344309211003378
0.4815161781394554,0.12377009500073573
0.4819144354226556,0.12377009500073573
0.4820282232178564,0.12377009500073573
0.4829385255794577,0.12352484283268021
0.483279888965055,0.12352484283268021
0.4833936767602558,0.12352484283268021
0.48441776691705785,0.12377009500073573
0.484872918097856,0.12377009500073573
0.48498670589305676,0.12368834427808928
0.48527117538105624,0.12368834427808928
0.48532806927865413,0.12360659355538475
================================================
FILE: demo/src/main/assets/tracks/track3.txt
================================================
0.4961379098226587,0.3391014985361062
0.5002342704498619,0.33877449564540424
0.5047288883602654,0.3378752376959448
0.5054116151314652,0.3382839913093513
0.5058098724146654,0.33836574203199776
0.5066632808786637,0.3382839913093513
0.5074029015474664,0.33779348697329836
0.5076873710354658,0.33730298263718733
0.5078011588306666,0.3364037246877279
0.5073460076498635,0.3355044667382685
0.5066632808786637,0.33501396240221554
0.5060943419026648,0.33485046095686455
0.5054685090290629,0.33493221167951104
0.5033065409202631,0.3343599566207536
0.5023393446610639,0.3343599566207536
0.4983567718290615,0.335586217460973
0.49545518305145897,0.3361584725197305
0.49391904781626084,0.3364037246877279
0.48703488620665586,0.3365672261330789
0.4856694326642565,0.3364037246877279
0.4846453425074544,0.3359949710743214
0.48401950963385765,0.33542271601562207
0.48316610116985426,0.3343599566207536
0.4816299659346562,0.3318256842177263
0.48049208798265325,0.3321526871084282
0.4741199714514493,0.3299454175961029
0.4700236108242511,0.3280651509745376
0.4664392952754489,0.3261848843529142
0.465301417323446,0.3255308785715103
0.46285497972664663,0.32373236267253336
0.46217225295544695,0.32356886122718237
0.4618877834674475,0.32324185833648045
0.461091268901047,0.32307835689112946
0.4588155129970463,0.32291485544577847
0.45779142284024427,0.322506101832372
0.45710869606904453,0.32201559749631903
0.45295544154424344,0.3178463106397239
0.4448196141874398,0.32626663507561876
0.4381630281682364,0.3321526871084282
0.4331563651794369,0.3368942290238389
0.43247363840823216,0.33779348697329836
0.43218916892023274,0.33861099420005325
0.4327012139986337,0.341554020216487
0.43218916892023274,0.34179927238454255
0.4322460628178356,0.3431890346700549
0.4320184872274341,0.34417004334221885
0.43173401773943454,0.3449057998463273
0.4296289435282326,0.3471948200812991
0.4217775856594284,0.35422538223168154
0.4180225884178275,0.35782241402951925
0.4078385807474248,0.36656974135611614
0.4060748699218252,0.368204755809684
0.39964585949302334,0.37261929483433465
0.3858206423762155,0.38193887721968894
0.38212253903221244,0.3847184017907717
0.3819518573394138,0.3845549003454207
0.38138291836341487,0.3845549003454207
0.38115534277301333,0.3846366510681253
0.38098466108021467,0.3850454046814737
0.38098466108021467,0.3852906568495292
0.37944852584501154,0.38602641335363763
0.36835421581300754,0.3923212189999116
0.354415210901004,0.40098679560380396
0.35350490853940264,0.4017225521079124
0.35231013668980193,0.4030305636707783
0.3510584709426033,0.40499258101504815
0.3483844577553973,0.4113691373840267
0.34724657980339946,0.413576406896294
0.34559665677300067,0.4159471778540284
0.34332090086899997,0.4181544473662956
0.34030552429619665,0.42036171687862095
0.33353515048179244,0.42453100373527425
0.32636651938419303,0.4304170557680837
0.32374940009458997,0.4321338209443561
0.31117584872498577,0.43859212803598113
0.3077622148689822,0.44071764682560194
0.30747774538098277,0.44047239465760446
0.30668123081458226,0.4403906439348999
0.3062260796337842,0.44055414538025095
0.3059985040433826,0.44088114827095287
0.3058278223505839,0.44145340332971034
0.30332449085618163,0.44202565838846786
0.2987160851505773,0.44267966416987176
0.296269647553778,0.44251616272452077
0.2939369977521794,0.4421074091111724
0.28386677787697245,0.4391643830947386
0.2817617036657704,0.4391643830947386
0.2809651890993699,0.43924613381738503
0.2796566294545734,0.4396548874307915
0.2773808735505727,0.44071764682560194
0.27556026882737017,0.44137165260706396
0.27191905938097005,0.4421891598338188
0.267196865880165,0.44292491633792724
0.2641245954097638,0.4433336699513337
0.25615944974576393,0.4427614148925763
0.253485436558563,0.44292491633792724
0.25018559049776024,0.44374242356474025
0.24785294069615657,0.44472343223684613
0.24625991156335558,0.44586794235436106
0.24415483735215862,0.4478299596986309
0.24227733873135818,0.4491379712614968
0.24005847672495534,0.4503642321016581
0.23766893302575387,0.45126349005117566
0.23641726727855525,0.45159049294187764
0.22776939484335063,0.4523262494459861
0.22372992811375028,0.452816753782039
0.2167888726065474,0.4532255073954455
0.21166842182254206,0.45420651606755136
0.21155463402734126,0.45404301462220037
0.21109948284654315,0.45371601173149845
0.21058743776814212,0.4538795131768494
0.21030296828014267,0.4542882667902559
0.21035986217774053,0.45461526968095783
0.21070122556334292,0.45502402329436437
0.21041675607534346,0.4553510261850663
0.2062066076529394,0.4574765449747452
0.19812767419373872,0.46238158833544873
0.19602259998253668,0.4640166027890167
0.19351926848813442,0.4663056230239884
0.1927227539217339,0.46728663169615237
0.18794366652333097,0.4739084402331284
0.18396109369133354,0.48044849804739986
0.18310768522733012,0.4800397444339934
0.1825387462513312,0.4800397444339934
0.18020609644973265,0.4808572516608064
0.17940958188333214,0.48077550093810184
0.1788406429073282,0.48036674732475343
0.17861306731693166,0.4794674893752359
0.178954430702529,0.47873173287112747
0.18117329270893182,0.47644271263615573
0.18231117066092964,0.47488944890523427
0.18242495845613044,0.47448069529188586
0.18242495845613044,0.4739084402331284
0.18219738286572884,0.47358143734242647
0.17935268798572926,0.4707201620486391
0.17832859782892715,0.4701479069898816
0.17639420531052885,0.4696574026538287
0.175768372436927,0.47039315915793717
0.17480117617772786,0.4713741678301011
0.17406155550892524,0.472518677947558
0.17400466161132735,0.47292743156096445
0.17406155550892524,0.4739901909557749
0.17457360058732627,0.4755434546866963
0.17457360058732627,0.47627921119080474
0.17434602499692975,0.4770149676949132
0.17411844940652815,0.4773419705856151
0.1721840568881248,0.4788952343165365
0.16825837795372522,0.4815112574422103
0.16450338071212436,0.4842907820132931
0.1602932322897203,0.4862527993575629
0.15750543130731856,0.48739730947507787
0.1538642218609185,0.4883783181472418
0.1521574049329167,0.4883783181472418
0.15107642087851678,0.4881330659791863
0.14692316635371563,0.48617104863491645
0.14618354568491806,0.48592579646686096
0.14544392501611542,0.4858440457442145
0.14430604706411762,0.48633455008026744
0.14413536537131388,0.486579802248323
0.1433388508049134,0.48633455008026744
0.14214407895531267,0.4864163008029139
0.1385597634065155,0.4872338080297269
0.13793393053291367,0.4871520573070223
0.13628400750250974,0.4864980515256184
0.13213075297770863,0.4841272805679421
0.12405181951850794,0.4778324749216681
0.11944341381290363,0.47382668951042384
0.11711076401130507,0.4712106663847501
0.11540394708330329,0.46843114181366735
0.11437985692650121,0.46589686941064
0.11341266066730205,0.46156408110863584
0.11250235830570077,0.4568225391932832
0.11170584373930029,0.4542882667902559
0.11090932917289978,0.45257150161398346
0.10954387563050036,0.4503642321016581
0.10652849905770212,0.446685449581174
0.10436653094889717,0.44259791344722527
0.10294418350889989,0.44055414538025095
0.10066842760489919,0.4385103773132766
0.09429631107369525,0.43417758901133047
0.0931015392240945,0.43295132817116905
0.09179297957929297,0.4300083021547353
0.0910533589104954,0.4287820413145158
0.08837934572329446,0.42493975734862255
0.08798108844009422,0.4242040008445142
0.08763972505449184,0.42224198350024433
0.0865587410000919,0.42019821543326996
0.08581912033128927,0.4180726966436492
0.08559154474089273,0.4177456937529472
0.08388472781289097,0.4162741807447303
0.08104003293289136,0.4133311547282965
0.07927632210728666,0.4112873866613222
0.07677299061288943,0.40867136353559036
0.07609026384168469,0.40801735775418646
0.073302462859288,0.4061370911325631
0.06442701483368177,0.3985342739234812
0.06146853215848138,0.39534599573899193
0.05714459594088159,0.38929644226077337
0.05572224850087926,0.3868439205804506
0.05287755362087966,0.3813666221609895
0.049691495355277684,0.37580757301882395
0.04741573945127699,0.3709025296581203
0.04622096760167625,0.36877701086844145
0.04468483236647314,0.3667332428014671
0.040133320558471755,0.3621552023314654
0.0383696097328721,0.3601931849871956
0.03751620126887374,0.3590486748696806
0.03637832331687087,0.35700490680276437
0.03535423316006879,0.3545523851223836
0.03484218808166775,0.3527538692234647
0.034557718593668296,0.35095535332454586
0.034443930798467505,0.349156837425627
0.034614612491271214,0.34629556213183965
0.035183551467270126,0.3435977882834614
0.036890368395271905,0.33771173625059386
0.037231731780869234,0.33607672179702597
0.03762998906406948,0.33329719722594314
0.0376868829616724,0.3317439334950798
0.03745930737127082,0.32904615964664347
0.036890368395271905,0.3264301365209697
0.035923172136072754,0.3239776148405889
0.034330143003271765,0.3210345888241551
0.028640753243267512,0.31261426438826023
0.026706360724869198,0.30991649053988196
0.025397801080067665,0.30819972536360957
0.022723787892866727,0.30542020079258486
0.018798108958462127,0.3019049197173936
0.017375761518464858,0.3010056617679342
0.011458796168059018,0.2977356328607404
0.011117432782461696,0.297326879247392
0.01094675108965798,0.29675462418863446
0.01094675108965798,0.2955283633484731
0.011174326680059563,0.2951196097350666
0.011857053451259265,0.2946291053989556
0.012198416836861643,0.2944656039536046
0.01265356801765976,0.2946291053989556
0.013620764276858915,0.2952831111804176
0.015498262897664414,0.29691812563398545
0.016408565259260644,0.29740862997003836
0.0177740188016651,0.2978173835834449
0.01868432116326134,0.29838963864220236
0.02340651466406643,0.302313673330742
0.024203029230466924,0.30321293128025956
0.025625376670464196,0.3059924558512842
0.02625120954406603,0.30623770801933975
0.02727529970086811,0.30607420657398876
0.027730450881666226,0.30542020079258486
0.027787344779269148,0.30476619501112284
0.027673556984068357,0.30435744139771637
0.0273890874960689,0.3040304385070144
0.024203029230466924,0.3019049197173936
0.01936704793446609,0.2978173835834449
0.018456745572864804,0.29724512852468743
0.01657924695206436,0.29642762129793254
0.016010307976060397,0.2958553662391751
0.01572583848806094,0.29520136045771306
0.015384475102463619,0.2936480967268497
0.015498262897664414,0.29242183588668835
0.015782732385663866,0.2916860793825799
0.01663614084966223,0.2904598185423604
0.01726197372326406,0.289887563483603
0.01822916998246322,0.28956056059290103
0.01931015403686317,0.28947880987025454
0.026820148520064938,0.2906233199877114
0.02744598139366677,0.29054156926506497
0.028185602062469395,0.290378067819714
0.028754541038468306,0.29005106492895394
0.02920969221926642,0.28956056059290103
0.029664843400069592,0.2888248040887926
0.029949312888069044,0.2875985432486312
0.029778631195270383,0.28130373760235716
0.028868328833669098,0.2781154594179259
0.02744598139366677,0.27435492617467916
0.02522711938726395,0.26642510607489533
0.02465818041126504,0.2630733264450551
0.024259923128064794,0.2616018134368382
0.022837575688067522,0.2583317845297024
0.02221174281446569,0.25628801646272803
0.021244546555266534,0.25236398177413033
0.021016970964864948,0.24982970937110305
0.021073864862462818,0.24794944274953776
0.021642803838466777,0.24353490372488706
0.01891189675366292,0.2156579072914707
0.017944700494463763,0.21173387260287296
0.01822916998246322,0.2116521218802265
0.018627427265663465,0.21107986682146906
0.018627427265663465,0.21075286393076706
0.018513639470462674,0.2104258610400651
0.018001594392061636,0.21009885814930507
0.0177740188016651,0.21009885814930507
0.0177740188016651,0.20887259730914373
0.017944700494463763,0.20715583213287128
0.014644854433660995,0.1739650387254029
0.014019021560059162,0.17126726487702462
0.012369098529660304,0.16922349681005028
0.010889857192060107,0.16799723596983082
0.003948801684857246,0.1641549520039376
0.0014454701904549755,0.16227468538237233
-0.0015130124847454185,0.1609666738195064
-0.004585282955146604,0.16006741587004697
-0.03957502997916037,0.1573696420216687
-0.04355760281116284,0.15761489418966612
-0.04606093430556512,0.15867765358447655
-0.057041456542368314,0.16366444766788468
-0.05823622839196906,0.16399145055858663
-0.060113727012769506,0.16390969983594017
-0.06182054394077128,0.16341919549982914
-0.06716857031517318,0.1607214216514509
-0.06819266047197017,0.16006741587004697
-0.0709804614543719,0.15875940430718105
-0.07337000515357339,0.15851415213912556
-0.07644227562397457,0.15875940430718105
-0.07951454609437575,0.1581871492484236
-0.0821885592815767,0.1573696420216687
-0.08412295179998008,0.15704263913090866
-0.08742279786078283,0.1571243898536132
-0.08856067581278065,0.15679738696291123
-0.08958476596958273,0.1563068826268002
-0.09077953781918346,0.15532587395469435
-0.09191741577118129,0.15450836672788137
-0.09305529372318416,0.1539361116691239
-0.09533104962718486,0.15336385661036644
-0.09595688250078163,0.15303685371966447
-0.09692407875998585,0.15221934649290958
-0.0986877895855855,0.1500938277032307
-0.09993945533278409,0.1479683089136099
-0.10102043938718408,0.1464150451826885
-0.10312551359838609,0.1446165292837696
-0.10392202816478657,0.1428997641075553
-0.10426339155038897,0.14036549170446994
-0.10471854273118708,0.13897572941895758
-0.1054581633999897,0.13807647146949814
-0.10631157186398807,0.13742246568809424
-0.1088717972559882,0.13619620484787476
-0.10961141792479082,0.1352151961757689
-0.11063550808158787,0.13276267449538803
-0.11126134095518969,0.13210866871398413
-0.11188717382879151,0.1317816658232241
-0.11461808091359033,0.1316181643778731
-0.11547148937759374,0.13120941076446663
-0.11934027441439543,0.12916564269755038
-0.12019368287839377,0.12802113258003545
-0.12116087913759294,0.1256503616223591
-0.12150224252319533,0.12254383416057436
-0.1222418631919929,0.12180807765646591
-0.12320905945119712,0.12148107476576395
-0.12747610177119903,0.12148107476576395
-0.12884155531359842,0.1211540718750039
-0.1341895816880003,0.11927380525343861
-0.13572571692319838,0.11829279658127466
-0.13709117046560282,0.11714828646375972
-0.13828594231520355,0.11502276767413892
-0.14135821278560476,0.10840095913716291
-0.14477184664160325,0.1043134230032142
-0.14727517813600552,0.1007163912053765
-0.14852684388320414,0.09957188108786157
-0.14926646455200676,0.0992448781971596
-0.151257750968008,0.0989996260291041
-0.1520542655344085,0.09916312747451317
-0.15683335293281145,0.10104339409607846
-0.1579143369872114,0.10177915060018691
-0.16115728915041125,0.1049674287846762
-0.16257963659041358,0.1062754403475421
-0.16639152772961233,0.10782870407840545
-0.16838281414561357,0.10840095913716291
-0.17043099445921267,0.10930021708662234
-0.17373084052001547,0.11036297648143273
-0.17794098894241953,0.11207974165770514
-0.18175288008161827,0.1124067445484071
-0.18357348480482086,0.11175273876700319
-0.1860768162992231,0.10897321419592038
-0.18738537594401958,0.107746953355759
-0.18840946610082165,0.10750170118770348
-0.18988870743842187,0.10758345191040802
-0.19091279759522395,0.10733819974235251
-0.19261961452322574,0.10611193890219113
-0.19347302298722407,0.10570318528878464
-0.19432643145122247,0.10562143456608011
-0.19569188499362689,0.10594843745684016
-0.1966021873552231,0.1066841939608905
-0.20121059306082748,0.11085348081754376
-0.20439665132642942,0.11355125466592203
-0.20684308892322875,0.11543152128754541
-0.20832233026082897,0.11641252995965128
-0.21116702514083363,0.11739353863181523
-0.21184975191203334,0.1179657936905727
-0.21236179699043436,0.11870155019468115
-0.21264626647843382,0.1200913124801935
-0.21213422140003277,0.1243423500594932
-0.21219111529763066,0.12646786884911398
-0.21241869088803225,0.1281846340253864
-0.21440997730403347,0.13210866871398413
-0.21497891628003238,0.1328444252180926
-0.2154909613584334,0.133253178831441
-0.21605990033443231,0.1334984309994965
-0.21714088438883225,0.1334984309994965
-0.21953042808803377,0.13317142810879457
-0.22066830604003665,0.13366193244484748
-0.2212941389136334,0.13423418750360494
-0.22169239619683365,0.13488819328500884
-0.22260269855843492,0.13856697580555108
-0.22305784973923812,0.1394662337550105
-0.2237974704080357,0.14003848881376796
-0.2248215605648378,0.14044724242717446
-0.22544739343843959,0.140528993149879
-0.226812846980839,0.1401202395364725
-0.22846277001123785,0.13856697580555108
-0.23107988930084092,0.13652320773857674
-0.23335564520484162,0.1345611903943069
-0.23420905366883996,0.133907184612903
-0.23517624992803912,0.1334984309994965
-0.23591587059684174,0.13333492955414553
-0.2398415495312413,0.13415243678090039
-0.24115010917604282,0.13415243678090039
-0.24627055996004818,0.13341668027679196
-0.24854631586404888,0.13259917305003707
-0.24939972432804722,0.13251742232733255
-0.2519599497200473,0.13308967738609
-0.254804644600052,0.1334984309994965
-0.2556580530640504,0.13374368316755203
-0.2586734296368537,0.13635970629322575
-0.2596406258960528,0.13701371207468777
-0.2604940343600512,0.13742246568809424
-0.26225774518565087,0.13742246568809424
-0.26584206073445305,0.13717721352003873
-0.2668092569936522,0.1373407149653897
-0.26771955935525354,0.1377494685787962
-0.26828849833125745,0.1381582221921446
-0.26891433120485425,0.13979323664577056
-0.2699953152592542,0.14142825109933843
-0.2723279650608578,0.1431450162755527
-0.27420546368165827,0.14420777567036314
-0.2747175087600593,0.14469828000647417
-0.2738641002960559,0.14469828000647417
-0.27289690403685674,0.14404427422501215
-0.2713038749040557,0.14339026844360825
-0.268004028843253,0.14224575832609332
-0.26453350108965157,0.14069249459522998
-0.26322494144485503,0.14036549170446994
-0.2620301695952543,0.14044724242717446
-0.26055092825765414,0.1407742453178764
-0.25702350660644974,0.14208225688074233
-0.2561132042448485,0.14232750904879785
-0.25491843239524775,0.1420005061580959
-0.2529271459792465,0.1411012482085784
-0.2524151009008505,0.14101949748593193
-0.25201684361765025,0.1411829989312829
-0.2517323741296508,0.14175525399004038
-0.2524151009008505,0.1441260249477167
-0.253211615467251,0.14616979301469105
-0.2543494934192488,0.14854056397236737
-0.25554426526884955,0.15001207698058425
-0.2564545676304509,0.15058433203934174
-0.25759244558244865,0.15082958420733916
-0.25946994420324915,0.15082958420733916
-0.26453350108965157,0.1503390798712862
-0.2653300156560521,0.1503390798712862
-0.26612653022245253,0.15082958420733916
-0.26629721191525624,0.15156534071144762
-0.2662403180176533,0.15230109721555604
-0.26584206073445305,0.15287335227431348
-0.2644197132944558,0.1536908595011265
-0.26003888317925306,0.15589812901345182
-0.2595837319984499,0.15663388551756025
-0.25946994420324915,0.15745139274431513
-0.2598113075888515,0.1581871492484236
-0.26066471605284985,0.15908640719788303
-0.264305925499255,0.16104842454215285
-0.2662403180176533,0.16153892887826388
-0.26715062037925463,0.16145717815555932
-0.26823160443365457,0.1612119259875619
-0.26971084577125476,0.1612119259875619
-0.2703366786448566,0.1613754274329129
-0.27073493592805686,0.1618659317689658
-0.2707918298256547,0.16235643610501876
-0.27073493592805686,0.16309219260912722
-0.27039357254245444,0.16341919549982914
-0.26971084577125476,0.16374619839058918
-0.266979938686456,0.16423670272664215
-0.26453350108965157,0.1654629635668035
-0.26385077431845183,0.16570821573485903
-0.26299736585445344,0.16570821573485903
-0.26038024656485037,0.1652177113988061
-0.25918547471524966,0.1652177113988061
-0.25713729440165056,0.1650542099534551
-0.2562838859376522,0.1650542099534551
-0.2538374483408478,0.16578996645756355
-0.2519599497200473,0.16644397223896745
-0.253211615467251,0.16767023307912884
-0.2545770690096504,0.16824248813788634
-0.25486153849764986,0.1685694910285883
-0.2564545676304509,0.17216652282648406
-0.25719418829924845,0.17322928222129444
-0.2652162278608563,0.185246638455027
-0.2703366786448566,0.19235895132805594
-0.2705642542352582,0.19358521216827543
-0.2704504664400574,0.19432096867238385
-0.2714176626992565,0.19464797156308583
-0.27238485895845566,0.19546547878984072
-0.2990680969328672,0.23437882278488378
-0.3051557439760717,0.24296264866612963
-0.30595225854247216,0.24386190661558904
-0.3061229402352708,0.24353490372488706
-0.3063505158256724,0.24337140227953613
-0.3066918792112697,0.24345315300218254
-0.30709013649447,0.24369840517023808
-0.30726081818727374,0.244188909506291
-0.30726081818727374,0.244842915287753
-0.30703324259687215,0.24516991817845496
-0.30674877310887266,0.24525166890110142
-0.3168189929840746,0.2674061147470012
-0.33513882801128086,0.30689171380074365
-0.33536640360168246,0.30836322680896056
-0.3357077669872849,0.3090172325904226
-0.33616291816808297,0.30926248475847806
-0.3367887510416848,0.30983473981717746
-0.33690253883688565,0.3106522470439904
-0.3366180693488811,0.3114697542707453
-0.33701632663208136,0.31261426438826023
-0.3374145839152816,0.31343177161507324
-0.33934897643368495,0.316374797631507
-0.34105579336168673,0.3195630758159382
-0.3439573821392842,0.32561262929415674
-0.345664199067286,0.32945491326004994
-0.34737101599528775,0.33288844361253667
-0.35823775043689016,0.35692315608005987
-0.3650650181488973,0.3723740426663372
-0.3654063815344946,0.37482656434666
-0.3651219120464952,0.37736083674968723
-0.36478054866089277,0.37776959036309377
-0.3646098669680941,0.3787505990352577
-0.3647236547632949,0.381121369992934
-0.36495123035369653,0.3825928830011509
-0.3681941825168964,0.38300163661455744
-0.36876312149289525,0.38300163661455744
-0.37331463330089665,0.38063086565688103
-0.37564728310250023,0.3799768598754191
-0.3822469752241007,0.3776060889177427
-0.3827590203025018,0.3771973353043363
-0.38292970199530046,0.37646157880022785
-0.38332795927850066,0.37613457590952587
-0.3846934128209052,0.37621632663223037
-0.3851485640017033,0.37646157880022785
-0.3854330334897027,0.3767885816909879
-0.3849778823089046,0.37801484253114925
-0.38520545789930116,0.37948635553936616
-0.38571750297770213,0.3800586105981236
-0.38787947108650206,0.3808761178248785
-0.3891311368337057,0.38185712649704245
-0.39009833309290487,0.38300163661455744
-0.39248787679210634,0.38520890612688274
-0.39328439135850685,0.3861081640763422
-0.39334128525610473,0.3864351669670441
-0.3931706035633061,0.387252674193799
-0.39248787679210634,0.38749792636185454
-0.391634468328108,0.387252674193799
-0.38901734903850493,0.3852906568495292
-0.38833462226730525,0.38512715540417825
-0.38787947108650206,0.38512715540417825
-0.38748121380330686,0.38537240757223373
-0.38708295652010666,0.38586291190828664
-0.38713985041770443,0.3865986684123951
-0.387424319905704,0.3871709234711525
-0.3882208344721045,0.38823368286596294
-0.3894725002193031,0.3891329408154224
-0.39675491911210836,0.3925664711679091
-0.39721007029290645,0.39297522478131564
-0.39760832757610665,0.3939562334534796
-0.3976652214737096,0.39510074357093644
-0.39726696419050933,0.3961635029658049
-0.3966980252145105,0.39689925946991333
-0.3957877228529091,0.3984525232007767
-0.3885621978577068,0.4036028187295358
-0.3837831104593038,0.40523783318310364
-0.38179182404330264,0.4062188418552676
-0.3814504606577002,0.40679109691402504
-0.381564248452901,0.40760860414078
-0.38264523250730104,0.4093253693170524
-0.3837831104593038,0.41014287654380727
-0.3850916701041054,0.41071513160256473
-0.3864571236465048,0.41046987943450924
-0.38787947108650206,0.4098976243758098
-0.3891311368337057,0.40957062148504975
-0.389529394116906,0.4096523722077543
-0.38987075750250333,0.4099793750984563
-0.3899845452977041,0.41038812871186275
-0.3899845452977041,0.4107968823252693
-0.3896431819121068,0.4112056359386177
-0.3876518954961055,0.41202314316543065
-0.38662780533930347,0.41259539822418806
-0.38355553486890226,0.4166011836354323
-0.3779799329040988,0.4201164647106236
-0.37684205495210105,0.4210974733827294
-0.37604554038570054,0.42158797771884043
-0.37496455633130055,0.42183322988683786
-0.3718922858608994,0.4213427255507849
-0.37064062011369575,0.42150622699613594
-0.3681941825168964,0.42224198350024433
-0.36546327543209756,0.4226507371136508
-0.36495123035369653,0.4229777400043528
-0.3647236547632949,0.42346824434040575
-0.36478054866089277,0.42395874867651673
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/MainActivity.kt
================================================
package ovh.plrapps.mapcompose.demo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import ovh.plrapps.mapcompose.demo.ui.MapComposeDemoApp
import ovh.plrapps.mapcompose.demo.ui.theme.MapComposeTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
MapComposeTheme {
MapComposeDemoApp()
}
}
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
MapComposeTheme {
MapComposeDemoApp()
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/providers/TileStreamProviderFactory.kt
================================================
package ovh.plrapps.mapcompose.demo.providers
import android.content.Context
import ovh.plrapps.mapcompose.core.TileStreamProvider
fun makeTileStreamProvider(appContext: Context) =
TileStreamProvider { row, col, zoomLvl ->
try {
appContext.assets?.open("tiles/mont_blanc_layered/$zoomLvl/$row/$col.jpg")
} catch (e: Exception) {
null
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/MapComposeDemoApp.kt
================================================
package ovh.plrapps.mapcompose.demo.ui
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import ovh.plrapps.mapcompose.demo.ui.screens.AddingMarkerDemo
import ovh.plrapps.mapcompose.demo.ui.screens.AnimationDemo
import ovh.plrapps.mapcompose.demo.ui.screens.CalloutDemo
import ovh.plrapps.mapcompose.demo.ui.screens.CenteringOnMarkerDemo
import ovh.plrapps.mapcompose.demo.ui.screens.CustomDrawDemo
import ovh.plrapps.mapcompose.demo.ui.screens.Home
import ovh.plrapps.mapcompose.demo.ui.screens.HttpTilesDemo
import ovh.plrapps.mapcompose.demo.ui.screens.InfiniteScrollDemo
import ovh.plrapps.mapcompose.demo.ui.screens.LayersDemoSimple
import ovh.plrapps.mapcompose.demo.ui.screens.MapDemoSimple
import ovh.plrapps.mapcompose.demo.ui.screens.MarkersClusteringDemo
import ovh.plrapps.mapcompose.demo.ui.screens.MarkersLazyLoadingDemo
import ovh.plrapps.mapcompose.demo.ui.screens.OsmDemo
import ovh.plrapps.mapcompose.demo.ui.screens.PathsDemo
import ovh.plrapps.mapcompose.demo.ui.screens.RotationDemo
import ovh.plrapps.mapcompose.demo.ui.screens.VisibleAreaPaddingDemo
import ovh.plrapps.mapcompose.demo.ui.theme.MapComposeTheme
@Composable
fun MapComposeDemoApp() {
val navController = rememberNavController()
MapComposeTheme {
NavHost(navController, startDestination = HOME) {
composable(HOME) {
Home(demoListState = rememberLazyListState()) {
navController.navigate(it.name)
}
}
composable(MainDestinations.MAP_ALONE.name) {
MapDemoSimple()
}
composable(MainDestinations.LAYERS_DEMO.name) {
LayersDemoSimple()
}
composable(MainDestinations.MAP_WITH_ROTATION_CONTROLS.name) {
RotationDemo()
}
composable(MainDestinations.ADDING_MARKERS.name) {
AddingMarkerDemo()
}
composable(MainDestinations.CENTERING_ON_MARKER.name) {
CenteringOnMarkerDemo()
}
composable(MainDestinations.PATHS.name) {
PathsDemo()
}
composable(MainDestinations.CUSTOM_DRAW.name) {
CustomDrawDemo()
}
composable(MainDestinations.CALLOUT_DEMO.name) {
CalloutDemo()
}
composable(MainDestinations.ANIMATION_DEMO.name) {
AnimationDemo()
}
composable(MainDestinations.INFINITE_SCROLL.name) {
InfiniteScrollDemo()
}
composable(MainDestinations.OSM_DEMO.name) {
OsmDemo()
}
composable(MainDestinations.HTTP_TILES_DEMO.name) {
HttpTilesDemo()
}
composable(MainDestinations.VISIBLE_AREA_PADDING.name) {
VisibleAreaPaddingDemo()
}
composable(MainDestinations.MARKERS_CLUSTERING.name) {
MarkersClusteringDemo()
}
composable(MainDestinations.MARKERS_LAZY_LOADING.name) {
MarkersLazyLoadingDemo()
}
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/NavGraph.kt
================================================
package ovh.plrapps.mapcompose.demo.ui
const val HOME = "home"
enum class MainDestinations(val title: String) {
MAP_ALONE("Simple map"),
LAYERS_DEMO("Layers demo"),
MAP_WITH_ROTATION_CONTROLS("Map with rotation controls"),
ADDING_MARKERS("Adding markers"),
CENTERING_ON_MARKER("Centering on marker"),
PATHS("Map with paths"),
CUSTOM_DRAW("Map with custom drawings"),
CALLOUT_DEMO("Callout (tap markers)"),
ANIMATION_DEMO("Animation demo"),
INFINITE_SCROLL("Infinite scroll demo"),
OSM_DEMO("Open Street Map demo"),
HTTP_TILES_DEMO("Remote HTTP tiles"),
VISIBLE_AREA_PADDING("Visible area padding"),
MARKERS_CLUSTERING("Markers clustering"),
MARKERS_LAZY_LOADING("Markers lazy loading")
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/AddingMarkerDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.AddingMarkerVM
import ovh.plrapps.mapcompose.ui.MapUI
@Composable
fun AddingMarkerDemo(
modifier: Modifier = Modifier,
viewModel: AddingMarkerVM = viewModel(),
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.ADDING_MARKERS.title) },
)
}
) { padding ->
Column(
modifier
.padding(padding)
.fillMaxSize()) {
MapUI(
modifier.weight(2f),
state = viewModel.state
)
Row(verticalAlignment = Alignment.CenterVertically) {
Button(onClick = {
viewModel.addMarker()
}, Modifier.padding(8.dp)) {
Text(text = "Add marker")
}
Text("Drag markers with finger")
}
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/AnimationDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.AnimationDemoVM
import ovh.plrapps.mapcompose.ui.MapUI
@Composable
fun AnimationDemo(
viewModel: AnimationDemoVM = viewModel(),
onRestart: () -> Unit = viewModel::startAnimation
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.ANIMATION_DEMO.title) },
)
}
) { padding ->
Column(
Modifier
.padding(padding)
.fillMaxSize()) {
MapUI(
Modifier.weight(1f),
state = viewModel.state
)
Button(onClick = {
onRestart()
}, Modifier.padding(8.dp)) {
Text(text = "Start")
}
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/CalloutDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.CalloutVM
import ovh.plrapps.mapcompose.ui.MapUI
@Composable
fun CalloutDemo(
viewModel: CalloutVM = viewModel()
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.CALLOUT_DEMO.title) },
)
}
) { padding ->
Column(
Modifier
.padding(padding)
.fillMaxSize()) {
MapUI(state = viewModel.state)
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/CenteringOnMarkerDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.CenteringOnMarkerVM
import ovh.plrapps.mapcompose.ui.MapUI
@Composable
fun CenteringOnMarkerDemo(
modifier: Modifier = Modifier,
viewModel: CenteringOnMarkerVM = viewModel(),
onCenter: () -> Unit = viewModel::onCenter
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.CENTERING_ON_MARKER.title) },
)
}
) { padding ->
Column(
modifier
.padding(padding)
.fillMaxSize()) {
MapUI(
modifier.weight(1f),
state = viewModel.state
)
Button(onClick = onCenter, Modifier.padding(8.dp)) {
Text(text = "Center on marker")
}
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/CustomDraw.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.api.DefaultCanvas
import ovh.plrapps.mapcompose.api.fullSize
import ovh.plrapps.mapcompose.api.scale
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.utils.pxToDp
import ovh.plrapps.mapcompose.demo.viewmodels.CustomDrawVM
import ovh.plrapps.mapcompose.ui.MapUI
import ovh.plrapps.mapcompose.ui.state.MapState
import kotlin.math.log10
import kotlin.math.pow
/**
* This demo shows how to embed custom drawings inside [MapUI].
*/
@Composable
fun CustomDrawDemo(viewModel: CustomDrawVM = viewModel()) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.CUSTOM_DRAW.title) },
)
}
) { padding ->
CustomDraw(Modifier.padding(padding), viewModel)
}
}
/**
* This demo shows how to embed custom drawings inside [MapUI].
*/
@Composable
private fun CustomDraw(
modifier: Modifier = Modifier, viewModel: CustomDrawVM
) {
Box(modifier) {
MapUI(state = viewModel.state) {
Square(
modifier = Modifier,
mapState = viewModel.state,
position = Offset(
viewModel.state.fullSize.width / 2f - 300f,
viewModel.state.fullSize.height / 2f - 300f
),
color = Color(0xff5c6bc0),
isScaling = true
)
Square(
modifier = Modifier,
mapState = viewModel.state,
position = Offset(
viewModel.state.fullSize.width / 2f,
viewModel.state.fullSize.height / 2f
),
color = Color(0xff087f23),
isScaling = false
)
Line(
modifier = Modifier,
mapState = viewModel.state,
color = Color(0xAAF44336),
p1 = with(viewModel) {
Offset(
(p1x * state.fullSize.width).toFloat(),
(p1y * state.fullSize.height).toFloat()
)
},
p2 = with(viewModel) {
Offset(
(p2x * state.fullSize.width).toFloat(),
(p2y * state.fullSize.height).toFloat()
)
}
)
}
ScaleIndicator(
controller = viewModel.scaleIndicatorController,
lineColor = Color.Black
)
}
}
@Composable
fun ScaleIndicator(
controller: ScaleIndicatorController,
lineColor: Color
) {
Box(Modifier.height(50.dp)) {
Canvas(
modifier = Modifier
.alpha(0.8f)
.padding(5.dp)
.size(pxToDp(controller.widthPx).dp, 15.dp)
) {
val width = controller.widthPx * controller.widthRatio
val height = size.height
drawLine(lineColor, Offset(0f, height / 2), Offset(width, height / 2), 2.dp.toPx())
drawLine(
lineColor,
Offset(0f, 0f),
Offset(0f, height),
2.dp.toPx(),
cap = StrokeCap.Round
)
drawLine(
lineColor,
Offset(width, 0f),
Offset(width, height),
2.dp.toPx(),
cap = StrokeCap.Round
)
}
Text(
text = controller.scaleText,
color = Color.White,
modifier = Modifier
.padding(start = 16.dp, top = 20.dp)
.background(color = Color(0x885D4037), shape = RoundedCornerShape(4.dp))
.padding(start = 5.dp, end = 5.dp)
)
}
}
class ScaleIndicatorController(val widthPx: Int, initScale: Double) {
var widthRatio by mutableFloatStateOf(0f)
var scaleText by mutableStateOf("")
private var snapScale: Double = initScale
private var snapWidthRatio = 0f
init {
snapToNewValue(initScale)
}
fun onScaleChanged(scale: Double) {
val ratio = (scale / snapScale).toFloat()
if (widthRatio * ratio in 0.5f..1f) {
widthRatio = snapWidthRatio * ratio
} else {
snapToNewValue(scale)
}
}
private fun snapToNewValue(scale: Double) {
val distance = distanceForPx(widthPx, scale)
val snap = computeSnapValue(distance) ?: return
snapScale = scale
widthRatio = snap.toFloat() / distance
snapWidthRatio = widthRatio
scaleText = formatDistance(snap)
}
/**
* Computes the distance in meters, given a size in pixels.
*/
private fun distanceForPx(nPx: Int, scale: Double): Int {
// TODO: This a simplified calculation
return (widthPx * 5 / scale).toInt()
}
private fun formatDistance(d: Int): String {
return "$d m"
}
/**
* A snap value is an entire multiple of power of 10, which is lower than [input].
* The first digit of a snap value is either 1, 2, 3, or 5.
* For example: 835 -> 500, 480 -> 300, 270 -> 200, 114 -> 100
* The snap value is always greater than half of [input].
*/
private fun computeSnapValue(input: Int): Int? {
if (input <= 1) return null
// Lowest entire power of 10
val power = (log10(input.toDouble())).toInt()
val power10 = 10.0.pow(power)
val mostSignificantDigit = (input / power10).toInt()
return when {
mostSignificantDigit >= 5 -> 5 * power10
mostSignificantDigit >= 3 -> 3 * power10
mostSignificantDigit >= 2 -> 2 * power10
else -> power10
}.toInt()
}
}
/**
* Here, we define a square with various inputs such as [position], [color], and [isScaling].
* Our custom composable is based on [DefaultCanvas], which is provided by the MapCompose library.
* Since [DefaultCanvas] moves, scales, and rotates with the map, so does our custom square composable.
*/
@Composable
fun Square(
modifier: Modifier,
mapState: MapState,
position: Offset,
color: Color,
isScaling: Boolean
) {
DefaultCanvas(
modifier = modifier,
mapState = mapState
) {
val side = if (isScaling) 300f else (300f / mapState.scale).toFloat()
drawRect(
color,
topLeft = position,
size = Size(side, side)
)
}
}
@Composable
fun Line(
modifier: Modifier,
mapState: MapState,
color: Color,
p1: Offset,
p2: Offset
) {
DefaultCanvas(
modifier = modifier,
mapState = mapState
) {
drawLine(color, start = p1, end = p2, strokeWidth = (8.0 / mapState.scale).toFloat())
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/Home.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import ovh.plrapps.mapcompose.demo.R
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
@Composable
fun Home(demoListState: LazyListState, onDemoSelected: (dest: MainDestinations) -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
)
}
) { padding ->
LazyColumn(
Modifier.padding(padding).fillMaxWidth(),
state = demoListState,
horizontalAlignment = Alignment.CenterHorizontally
) {
MainDestinations.entries.map { dest ->
item {
Button(
onClick = { onDemoSelected.invoke(dest) }
) {
Text(text = dest.title)
}
}
}
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/HttpTilesDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.HttpTilesVM
import ovh.plrapps.mapcompose.ui.MapUI
@Composable
fun HttpTilesDemo(
modifier: Modifier = Modifier, viewModel: HttpTilesVM = viewModel()
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.HTTP_TILES_DEMO.title) },
)
}
) { padding ->
MapUI(
modifier.padding(padding),
state = viewModel.state
)
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/InfiniteScrollDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.InfiniteScrollVM
import ovh.plrapps.mapcompose.ui.MapUI
@Composable
fun InfiniteScrollDemo(
modifier: Modifier = Modifier, viewModel: InfiniteScrollVM = viewModel()
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.INFINITE_SCROLL.title) },
)
}
) { padding ->
MapUI(modifier.padding(padding), state = viewModel.state)
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/LayersDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.LayersVM
import ovh.plrapps.mapcompose.ui.MapUI
import ovh.plrapps.mapcompose.ui.state.MapState
@Composable
fun LayersDemoSimple(viewModel: LayersVM = viewModel()) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.LAYERS_DEMO.title) },
)
}
) { padding ->
LayersDemoScreen(
Modifier.padding(padding),
viewModel.state,
onSlopesOpacity = viewModel::setSlopesOpacity,
onRoadOpacity = viewModel::setRoadOpacity
)
}
}
@Composable
fun LayersDemoScreen(
modifier: Modifier = Modifier,
mapState: MapState,
onSlopesOpacity: (Float) -> Unit,
onRoadOpacity: (Float) -> Unit
) {
var slopesSliderValue by remember {
mutableFloatStateOf(0.6f)
}
var roadSliderValue by remember {
mutableFloatStateOf(1f)
}
Column(modifier) {
MapUI(Modifier.weight(1f), state = mapState)
LayerSlider(
name = "Slopes",
value = slopesSliderValue,
onValueChange = {
slopesSliderValue = it
onSlopesOpacity(it)
}
)
LayerSlider(
name = "Roads",
value = roadSliderValue,
onValueChange = {
roadSliderValue = it
onRoadOpacity(it)
}
)
}
}
@Composable
private fun LayerSlider(name: String, value: Float, onValueChange: (Float) -> Unit) {
Row(
Modifier
.height(50.dp)
.padding(horizontal = 16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = name, Modifier.padding(horizontal = 16.dp))
Slider(
value = value,
onValueChange = onValueChange
)
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/MarkersClusteringDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.MarkersClusteringVM
import ovh.plrapps.mapcompose.ui.MapUI
@Composable
fun MarkersClusteringDemo(
modifier: Modifier = Modifier,
viewModel: MarkersClusteringVM = viewModel()
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.MARKERS_CLUSTERING.title) },
)
}
) { padding ->
MapUI(modifier.padding(padding), state = viewModel.state)
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/MarkersLazyLoadingDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.MarkersLazyLoadingVM
import ovh.plrapps.mapcompose.ui.MapUI
@Composable
fun MarkersLazyLoadingDemo(
modifier: Modifier = Modifier,
viewModel: MarkersLazyLoadingVM = viewModel()
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.MARKERS_LAZY_LOADING.title) },
)
}
) { padding ->
MapUI(modifier.padding(padding), state = viewModel.state)
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/OsmDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.OsmVM
import ovh.plrapps.mapcompose.ui.MapUI
@Composable
fun OsmDemo(
modifier: Modifier = Modifier, viewModel: OsmVM = viewModel()
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.OSM_DEMO.title) },
)
}
) { padding ->
MapUI(
modifier.padding(padding),
state = viewModel.state
)
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/PathsDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.PathsVM
import ovh.plrapps.mapcompose.ui.MapUI
@Composable
fun PathsDemo(
modifier: Modifier = Modifier, viewModel: PathsVM = viewModel()
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.PATHS.title) },
)
}
) { padding ->
MapUI(
modifier.padding(padding),
state = viewModel.state
)
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/RotationDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.api.rotation
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.RotationVM
import ovh.plrapps.mapcompose.ui.MapUI
import ovh.plrapps.mapcompose.ui.state.MapState
@Composable
fun RotationDemo(modifier: Modifier = Modifier, viewModel: RotationVM = viewModel()) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.MAP_WITH_ROTATION_CONTROLS.title) },
)
}
) { padding ->
RotationScreen(
modifier.padding(padding),
mapState = viewModel.state,
onRotate = viewModel::onRotate
)
}
}
@Composable
private fun RotationScreen(
modifier: Modifier = Modifier,
mapState: MapState,
onRotate: () -> Unit
) {
val sliderValue = mapState.rotation / 360f
Column(modifier.fillMaxSize()) {
MapUI(
Modifier.weight(1f),
state = mapState
)
Row {
Button(onClick = onRotate, Modifier.padding(8.dp)) {
Text(text = "Rotate 90°")
}
Slider(
value = sliderValue,
valueRange = 0f..0.9999f,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
onValueChange = { v -> mapState.rotation = v * 360f })
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/SimpleDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.SimpleDemoVM
import ovh.plrapps.mapcompose.ui.MapUI
@Composable
fun MapDemoSimple(
modifier: Modifier = Modifier, viewModel: SimpleDemoVM = viewModel()
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.MAP_ALONE.title) },
)
}
) { padding ->
MapUI(modifier.padding(padding), state = viewModel.state)
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/screens/VisibleAreaPaddingDemo.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.demo.ui.screens
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.api.centerOnMarker
import ovh.plrapps.mapcompose.api.setVisibleAreaPadding
import ovh.plrapps.mapcompose.demo.ui.MainDestinations
import ovh.plrapps.mapcompose.demo.viewmodels.VisibleAreaPaddingVM
import ovh.plrapps.mapcompose.ui.MapUI
import ovh.plrapps.mapcompose.ui.state.MapState
@Composable
fun VisibleAreaPaddingDemo(
viewModel: VisibleAreaPaddingVM = viewModel()
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(MainDestinations.VISIBLE_AREA_PADDING.title) },
)
}
) { padding ->
VisibleAreaPaddingScreen(Modifier.padding(padding), viewModel.state)
}
}
@Composable
private fun VisibleAreaPaddingScreen(
modifier: Modifier,
mapState: MapState
) {
val obstructionSize = 100.dp
val obstructionColor = Color(0xA0000000)
var leftObstructionEnabled by remember { mutableStateOf(true) }
var rightObstructionEnabled by remember { mutableStateOf(false) }
var topObstructionEnabled by remember { mutableStateOf(false) }
var bottomObstructionEnabled by remember { mutableStateOf(false) }
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
topObstructionEnabled = !topObstructionEnabled
},
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Switch(topObstructionEnabled, onCheckedChange = null)
Text(
"Top ", // Same width as "Bottom"
modifier = Modifier.padding(start = 4.dp),
fontFamily = FontFamily.Monospace
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
topObstructionEnabled = !topObstructionEnabled
},
horizontalArrangement = Arrangement.SpaceAround
) {
Row(
modifier = Modifier.clickable {
leftObstructionEnabled = !leftObstructionEnabled
},
verticalAlignment = Alignment.CenterVertically
) {
Switch(leftObstructionEnabled, onCheckedChange = null)
Text(
"Left",
modifier = Modifier.padding(start = 4.dp),
fontFamily = FontFamily.Monospace
)
}
Row(
modifier = Modifier.clickable {
rightObstructionEnabled = !rightObstructionEnabled
},
verticalAlignment = Alignment.CenterVertically
) {
Switch(rightObstructionEnabled, onCheckedChange = null)
Text(
"Right",
modifier = Modifier.padding(start = 4.dp),
fontFamily = FontFamily.Monospace
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
bottomObstructionEnabled = !bottomObstructionEnabled
},
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Switch(bottomObstructionEnabled, onCheckedChange = null)
Text(
"Bottom",
modifier = Modifier.padding(start = 4.dp),
fontFamily = FontFamily.Monospace
)
}
Spacer(Modifier.height(8.dp))
Box {
MapUI(
state = mapState
)
androidx.compose.animation.AnimatedVisibility(
visible = leftObstructionEnabled,
enter = expandHorizontally(),
exit = shrinkHorizontally(),
modifier = Modifier.align(Alignment.CenterStart)
) {
Surface(
color = obstructionColor,
modifier = Modifier
.fillMaxHeight()
.width(obstructionSize)
) {}
}
androidx.compose.animation.AnimatedVisibility(
visible = rightObstructionEnabled,
enter = expandHorizontally(expandFrom = Alignment.Start),
exit = shrinkHorizontally(),
modifier = Modifier.align(Alignment.CenterEnd)
) {
Surface(
color = obstructionColor,
modifier = Modifier
.fillMaxHeight()
.width(obstructionSize)
) {}
}
androidx.compose.animation.AnimatedVisibility(
visible = topObstructionEnabled,
enter = expandVertically(),
exit = shrinkVertically(),
modifier = Modifier.align(Alignment.TopCenter)
) {
Surface(
color = obstructionColor,
modifier = Modifier
.fillMaxWidth()
.height(obstructionSize)
) {}
}
androidx.compose.animation.AnimatedVisibility(
visible = bottomObstructionEnabled,
enter = expandVertically(),
exit = shrinkVertically(),
modifier = Modifier.align(Alignment.BottomCenter)
) {
Surface(
color = obstructionColor,
modifier = Modifier
.fillMaxWidth()
.height(obstructionSize)
) {}
}
}
}
LaunchedEffect(
leftObstructionEnabled,
rightObstructionEnabled,
topObstructionEnabled,
bottomObstructionEnabled
) {
mapState.setVisibleAreaPadding(
left = if (leftObstructionEnabled) obstructionSize else 0.dp,
right = if (rightObstructionEnabled) obstructionSize else 0.dp,
top = if (topObstructionEnabled) obstructionSize else 0.dp,
bottom = if (bottomObstructionEnabled) obstructionSize else 0.dp
)
mapState.centerOnMarker("m0")
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/theme/Color.kt
================================================
package ovh.plrapps.mapcompose.demo.ui.theme
import androidx.compose.ui.graphics.Color
val primaryLight = Color(0xFF415F91)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFD6E3FF)
val onPrimaryContainerLight = Color(0xFF001B3E)
val secondaryLight = Color(0xFF565F71)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFDAE2F9)
val onSecondaryContainerLight = Color(0xFF131C2B)
val tertiaryLight = Color(0xFF705575)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFFFAD8FD)
val onTertiaryContainerLight = Color(0xFF28132E)
val errorLight = Color(0xFFBA1A1A)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF410002)
val backgroundLight = Color(0xFFF9F9FF)
val onBackgroundLight = Color(0xFF191C20)
val surfaceLight = Color(0xFFF9F9FF)
val onSurfaceLight = Color(0xFF191C20)
val surfaceVariantLight = Color(0xFFE0E2EC)
val onSurfaceVariantLight = Color(0xFF44474E)
val outlineLight = Color(0xFF74777F)
val outlineVariantLight = Color(0xFFC4C6D0)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF2E3036)
val inverseOnSurfaceLight = Color(0xFFF0F0F7)
val inversePrimaryLight = Color(0xFFAAC7FF)
val surfaceDimLight = Color(0xFFD9D9E0)
val surfaceBrightLight = Color(0xFFF9F9FF)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFF3F3FA)
val surfaceContainerLight = Color(0xFFEDEDF4)
val surfaceContainerHighLight = Color(0xFFE7E8EE)
val surfaceContainerHighestLight = Color(0xFFE2E2E9)
val primaryLightMediumContrast = Color(0xFF234373)
val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
val primaryContainerLightMediumContrast = Color(0xFF5875A8)
val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val secondaryLightMediumContrast = Color(0xFF3A4354)
val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
val secondaryContainerLightMediumContrast = Color(0xFF6C7588)
val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryLightMediumContrast = Color(0xFF523A58)
val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightMediumContrast = Color(0xFF876B8C)
val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val errorLightMediumContrast = Color(0xFF8C0009)
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
val errorContainerLightMediumContrast = Color(0xFFDA342E)
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
val backgroundLightMediumContrast = Color(0xFFF9F9FF)
val onBackgroundLightMediumContrast = Color(0xFF191C20)
val surfaceLightMediumContrast = Color(0xFFF9F9FF)
val onSurfaceLightMediumContrast = Color(0xFF191C20)
val surfaceVariantLightMediumContrast = Color(0xFFE0E2EC)
val onSurfaceVariantLightMediumContrast = Color(0xFF40434A)
val outlineLightMediumContrast = Color(0xFF5C5F67)
val outlineVariantLightMediumContrast = Color(0xFF787A83)
val scrimLightMediumContrast = Color(0xFF000000)
val inverseSurfaceLightMediumContrast = Color(0xFF2E3036)
val inverseOnSurfaceLightMediumContrast = Color(0xFFF0F0F7)
val inversePrimaryLightMediumContrast = Color(0xFFAAC7FF)
val surfaceDimLightMediumContrast = Color(0xFFD9D9E0)
val surfaceBrightLightMediumContrast = Color(0xFFF9F9FF)
val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightMediumContrast = Color(0xFFF3F3FA)
val surfaceContainerLightMediumContrast = Color(0xFFEDEDF4)
val surfaceContainerHighLightMediumContrast = Color(0xFFE7E8EE)
val surfaceContainerHighestLightMediumContrast = Color(0xFFE2E2E9)
val primaryLightHighContrast = Color(0xFF00214A)
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
val primaryContainerLightHighContrast = Color(0xFF234373)
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
val secondaryLightHighContrast = Color(0xFF192232)
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
val secondaryContainerLightHighContrast = Color(0xFF3A4354)
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
val tertiaryLightHighContrast = Color(0xFF301A35)
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightHighContrast = Color(0xFF523A58)
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
val errorLightHighContrast = Color(0xFF4E0002)
val onErrorLightHighContrast = Color(0xFFFFFFFF)
val errorContainerLightHighContrast = Color(0xFF8C0009)
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
val backgroundLightHighContrast = Color(0xFFF9F9FF)
val onBackgroundLightHighContrast = Color(0xFF191C20)
val surfaceLightHighContrast = Color(0xFFF9F9FF)
val onSurfaceLightHighContrast = Color(0xFF000000)
val surfaceVariantLightHighContrast = Color(0xFFE0E2EC)
val onSurfaceVariantLightHighContrast = Color(0xFF21242B)
val outlineLightHighContrast = Color(0xFF40434A)
val outlineVariantLightHighContrast = Color(0xFF40434A)
val scrimLightHighContrast = Color(0xFF000000)
val inverseSurfaceLightHighContrast = Color(0xFF2E3036)
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
val inversePrimaryLightHighContrast = Color(0xFFE5ECFF)
val surfaceDimLightHighContrast = Color(0xFFD9D9E0)
val surfaceBrightLightHighContrast = Color(0xFFF9F9FF)
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightHighContrast = Color(0xFFF3F3FA)
val surfaceContainerLightHighContrast = Color(0xFFEDEDF4)
val surfaceContainerHighLightHighContrast = Color(0xFFE7E8EE)
val surfaceContainerHighestLightHighContrast = Color(0xFFE2E2E9)
val primaryDark = Color(0xFFAAC7FF)
val onPrimaryDark = Color(0xFF0A305F)
val primaryContainerDark = Color(0xFF284777)
val onPrimaryContainerDark = Color(0xFFD6E3FF)
val secondaryDark = Color(0xFFBEC6DC)
val onSecondaryDark = Color(0xFF283141)
val secondaryContainerDark = Color(0xFF3E4759)
val onSecondaryContainerDark = Color(0xFFDAE2F9)
val tertiaryDark = Color(0xFFDDBCE0)
val onTertiaryDark = Color(0xFF3F2844)
val tertiaryContainerDark = Color(0xFF573E5C)
val onTertiaryContainerDark = Color(0xFFFAD8FD)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color(0xFF111318)
val onBackgroundDark = Color(0xFFE2E2E9)
val surfaceDark = Color(0xFF111318)
val onSurfaceDark = Color(0xFFE2E2E9)
val surfaceVariantDark = Color(0xFF44474E)
val onSurfaceVariantDark = Color(0xFFC4C6D0)
val outlineDark = Color(0xFF8E9099)
val outlineVariantDark = Color(0xFF44474E)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFE2E2E9)
val inverseOnSurfaceDark = Color(0xFF2E3036)
val inversePrimaryDark = Color(0xFF415F91)
val surfaceDimDark = Color(0xFF111318)
val surfaceBrightDark = Color(0xFF37393E)
val surfaceContainerLowestDark = Color(0xFF0C0E13)
val surfaceContainerLowDark = Color(0xFF191C20)
val surfaceContainerDark = Color(0xFF1D2024)
val surfaceContainerHighDark = Color(0xFF282A2F)
val surfaceContainerHighestDark = Color(0xFF33353A)
val primaryDarkMediumContrast = Color(0xFFB1CBFF)
val onPrimaryDarkMediumContrast = Color(0xFF001634)
val primaryContainerDarkMediumContrast = Color(0xFF7491C7)
val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
val secondaryDarkMediumContrast = Color(0xFFC2CBE0)
val onSecondaryDarkMediumContrast = Color(0xFF0D1626)
val secondaryContainerDarkMediumContrast = Color(0xFF8891A5)
val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
val tertiaryDarkMediumContrast = Color(0xFFE1C0E5)
val onTertiaryDarkMediumContrast = Color(0xFF230E29)
val tertiaryContainerDarkMediumContrast = Color(0xFFA487A9)
val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
val errorDarkMediumContrast = Color(0xFFFFBAB1)
val onErrorDarkMediumContrast = Color(0xFF370001)
val errorContainerDarkMediumContrast = Color(0xFFFF5449)
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
val backgroundDarkMediumContrast = Color(0xFF111318)
val onBackgroundDarkMediumContrast = Color(0xFFE2E2E9)
val surfaceDarkMediumContrast = Color(0xFF111318)
val onSurfaceDarkMediumContrast = Color(0xFFFBFAFF)
val surfaceVariantDarkMediumContrast = Color(0xFF44474E)
val onSurfaceVariantDarkMediumContrast = Color(0xFFC8CAD4)
val outlineDarkMediumContrast = Color(0xFFA0A3AC)
val outlineVariantDarkMediumContrast = Color(0xFF80838C)
val scrimDarkMediumContrast = Color(0xFF000000)
val inverseSurfaceDarkMediumContrast = Color(0xFFE2E2E9)
val inverseOnSurfaceDarkMediumContrast = Color(0xFF282A2F)
val inversePrimaryDarkMediumContrast = Color(0xFF294878)
val surfaceDimDarkMediumContrast = Color(0xFF111318)
val surfaceBrightDarkMediumContrast = Color(0xFF37393E)
val surfaceContainerLowestDarkMediumContrast = Color(0xFF0C0E13)
val surfaceContainerLowDarkMediumContrast = Color(0xFF191C20)
val surfaceContainerDarkMediumContrast = Color(0xFF1D2024)
val surfaceContainerHighDarkMediumContrast = Color(0xFF282A2F)
val surfaceContainerHighestDarkMediumContrast = Color(0xFF33353A)
val primaryDarkHighContrast = Color(0xFFFBFAFF)
val onPrimaryDarkHighContrast = Color(0xFF000000)
val primaryContainerDarkHighContrast = Color(0xFFB1CBFF)
val onPrimaryContainerDarkHighContrast = Color(0xFF000000)
val secondaryDarkHighContrast = Color(0xFFFBFAFF)
val onSecondaryDarkHighContrast = Color(0xFF000000)
val secondaryContainerDarkHighContrast = Color(0xFFC2CBE0)
val onSecondaryContainerDarkHighContrast = Color(0xFF000000)
val tertiaryDarkHighContrast = Color(0xFFFFF9FA)
val onTertiaryDarkHighContrast = Color(0xFF000000)
val tertiaryContainerDarkHighContrast = Color(0xFFE1C0E5)
val onTertiaryContainerDarkHighContrast = Color(0xFF000000)
val errorDarkHighContrast = Color(0xFFFFF9F9)
val onErrorDarkHighContrast = Color(0xFF000000)
val errorContainerDarkHighContrast = Color(0xFFFFBAB1)
val onErrorContainerDarkHighContrast = Color(0xFF000000)
val backgroundDarkHighContrast = Color(0xFF111318)
val onBackgroundDarkHighContrast = Color(0xFFE2E2E9)
val surfaceDarkHighContrast = Color(0xFF111318)
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkHighContrast = Color(0xFF44474E)
val onSurfaceVariantDarkHighContrast = Color(0xFFFBFAFF)
val outlineDarkHighContrast = Color(0xFFC8CAD4)
val outlineVariantDarkHighContrast = Color(0xFFC8CAD4)
val scrimDarkHighContrast = Color(0xFF000000)
val inverseSurfaceDarkHighContrast = Color(0xFFE2E2E9)
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
val inversePrimaryDarkHighContrast = Color(0xFF002959)
val surfaceDimDarkHighContrast = Color(0xFF111318)
val surfaceBrightDarkHighContrast = Color(0xFF37393E)
val surfaceContainerLowestDarkHighContrast = Color(0xFF0C0E13)
val surfaceContainerLowDarkHighContrast = Color(0xFF191C20)
val surfaceContainerDarkHighContrast = Color(0xFF1D2024)
val surfaceContainerHighDarkHighContrast = Color(0xFF282A2F)
val surfaceContainerHighestDarkHighContrast = Color(0xFF33353A)
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/theme/Theme.kt
================================================
package ovh.plrapps.mapcompose.demo.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val lightScheme = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
outline = outlineLight,
outlineVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
surfaceDim = surfaceDimLight,
surfaceBright = surfaceBrightLight,
surfaceContainerLowest = surfaceContainerLowestLight,
surfaceContainerLow = surfaceContainerLowLight,
surfaceContainer = surfaceContainerLight,
surfaceContainerHigh = surfaceContainerHighLight,
surfaceContainerHighest = surfaceContainerHighestLight,
)
private val darkScheme = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
outline = outlineDark,
outlineVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
surfaceDim = surfaceDimDark,
surfaceBright = surfaceBrightDark,
surfaceContainerLowest = surfaceContainerLowestDark,
surfaceContainerLow = surfaceContainerLowDark,
surfaceContainer = surfaceContainerDark,
surfaceContainerHigh = surfaceContainerHighDark,
surfaceContainerHighest = surfaceContainerHighestDark,
)
private val mediumContrastLightColorScheme = lightColorScheme(
primary = primaryLightMediumContrast,
onPrimary = onPrimaryLightMediumContrast,
primaryContainer = primaryContainerLightMediumContrast,
onPrimaryContainer = onPrimaryContainerLightMediumContrast,
secondary = secondaryLightMediumContrast,
onSecondary = onSecondaryLightMediumContrast,
secondaryContainer = secondaryContainerLightMediumContrast,
onSecondaryContainer = onSecondaryContainerLightMediumContrast,
tertiary = tertiaryLightMediumContrast,
onTertiary = onTertiaryLightMediumContrast,
tertiaryContainer = tertiaryContainerLightMediumContrast,
onTertiaryContainer = onTertiaryContainerLightMediumContrast,
error = errorLightMediumContrast,
onError = onErrorLightMediumContrast,
errorContainer = errorContainerLightMediumContrast,
onErrorContainer = onErrorContainerLightMediumContrast,
background = backgroundLightMediumContrast,
onBackground = onBackgroundLightMediumContrast,
surface = surfaceLightMediumContrast,
onSurface = onSurfaceLightMediumContrast,
surfaceVariant = surfaceVariantLightMediumContrast,
onSurfaceVariant = onSurfaceVariantLightMediumContrast,
outline = outlineLightMediumContrast,
outlineVariant = outlineVariantLightMediumContrast,
scrim = scrimLightMediumContrast,
inverseSurface = inverseSurfaceLightMediumContrast,
inverseOnSurface = inverseOnSurfaceLightMediumContrast,
inversePrimary = inversePrimaryLightMediumContrast,
surfaceDim = surfaceDimLightMediumContrast,
surfaceBright = surfaceBrightLightMediumContrast,
surfaceContainerLowest = surfaceContainerLowestLightMediumContrast,
surfaceContainerLow = surfaceContainerLowLightMediumContrast,
surfaceContainer = surfaceContainerLightMediumContrast,
surfaceContainerHigh = surfaceContainerHighLightMediumContrast,
surfaceContainerHighest = surfaceContainerHighestLightMediumContrast,
)
private val highContrastLightColorScheme = lightColorScheme(
primary = primaryLightHighContrast,
onPrimary = onPrimaryLightHighContrast,
primaryContainer = primaryContainerLightHighContrast,
onPrimaryContainer = onPrimaryContainerLightHighContrast,
secondary = secondaryLightHighContrast,
onSecondary = onSecondaryLightHighContrast,
secondaryContainer = secondaryContainerLightHighContrast,
onSecondaryContainer = onSecondaryContainerLightHighContrast,
tertiary = tertiaryLightHighContrast,
onTertiary = onTertiaryLightHighContrast,
tertiaryContainer = tertiaryContainerLightHighContrast,
onTertiaryContainer = onTertiaryContainerLightHighContrast,
error = errorLightHighContrast,
onError = onErrorLightHighContrast,
errorContainer = errorContainerLightHighContrast,
onErrorContainer = onErrorContainerLightHighContrast,
background = backgroundLightHighContrast,
onBackground = onBackgroundLightHighContrast,
surface = surfaceLightHighContrast,
onSurface = onSurfaceLightHighContrast,
surfaceVariant = surfaceVariantLightHighContrast,
onSurfaceVariant = onSurfaceVariantLightHighContrast,
outline = outlineLightHighContrast,
outlineVariant = outlineVariantLightHighContrast,
scrim = scrimLightHighContrast,
inverseSurface = inverseSurfaceLightHighContrast,
inverseOnSurface = inverseOnSurfaceLightHighContrast,
inversePrimary = inversePrimaryLightHighContrast,
surfaceDim = surfaceDimLightHighContrast,
surfaceBright = surfaceBrightLightHighContrast,
surfaceContainerLowest = surfaceContainerLowestLightHighContrast,
surfaceContainerLow = surfaceContainerLowLightHighContrast,
surfaceContainer = surfaceContainerLightHighContrast,
surfaceContainerHigh = surfaceContainerHighLightHighContrast,
surfaceContainerHighest = surfaceContainerHighestLightHighContrast,
)
private val mediumContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkMediumContrast,
onPrimary = onPrimaryDarkMediumContrast,
primaryContainer = primaryContainerDarkMediumContrast,
onPrimaryContainer = onPrimaryContainerDarkMediumContrast,
secondary = secondaryDarkMediumContrast,
onSecondary = onSecondaryDarkMediumContrast,
secondaryContainer = secondaryContainerDarkMediumContrast,
onSecondaryContainer = onSecondaryContainerDarkMediumContrast,
tertiary = tertiaryDarkMediumContrast,
onTertiary = onTertiaryDarkMediumContrast,
tertiaryContainer = tertiaryContainerDarkMediumContrast,
onTertiaryContainer = onTertiaryContainerDarkMediumContrast,
error = errorDarkMediumContrast,
onError = onErrorDarkMediumContrast,
errorContainer = errorContainerDarkMediumContrast,
onErrorContainer = onErrorContainerDarkMediumContrast,
background = backgroundDarkMediumContrast,
onBackground = onBackgroundDarkMediumContrast,
surface = surfaceDarkMediumContrast,
onSurface = onSurfaceDarkMediumContrast,
surfaceVariant = surfaceVariantDarkMediumContrast,
onSurfaceVariant = onSurfaceVariantDarkMediumContrast,
outline = outlineDarkMediumContrast,
outlineVariant = outlineVariantDarkMediumContrast,
scrim = scrimDarkMediumContrast,
inverseSurface = inverseSurfaceDarkMediumContrast,
inverseOnSurface = inverseOnSurfaceDarkMediumContrast,
inversePrimary = inversePrimaryDarkMediumContrast,
surfaceDim = surfaceDimDarkMediumContrast,
surfaceBright = surfaceBrightDarkMediumContrast,
surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast,
surfaceContainerLow = surfaceContainerLowDarkMediumContrast,
surfaceContainer = surfaceContainerDarkMediumContrast,
surfaceContainerHigh = surfaceContainerHighDarkMediumContrast,
surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast,
)
private val highContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkHighContrast,
onPrimary = onPrimaryDarkHighContrast,
primaryContainer = primaryContainerDarkHighContrast,
onPrimaryContainer = onPrimaryContainerDarkHighContrast,
secondary = secondaryDarkHighContrast,
onSecondary = onSecondaryDarkHighContrast,
secondaryContainer = secondaryContainerDarkHighContrast,
onSecondaryContainer = onSecondaryContainerDarkHighContrast,
tertiary = tertiaryDarkHighContrast,
onTertiary = onTertiaryDarkHighContrast,
tertiaryContainer = tertiaryContainerDarkHighContrast,
onTertiaryContainer = onTertiaryContainerDarkHighContrast,
error = errorDarkHighContrast,
onError = onErrorDarkHighContrast,
errorContainer = errorContainerDarkHighContrast,
onErrorContainer = onErrorContainerDarkHighContrast,
background = backgroundDarkHighContrast,
onBackground = onBackgroundDarkHighContrast,
surface = surfaceDarkHighContrast,
onSurface = onSurfaceDarkHighContrast,
surfaceVariant = surfaceVariantDarkHighContrast,
onSurfaceVariant = onSurfaceVariantDarkHighContrast,
outline = outlineDarkHighContrast,
outlineVariant = outlineVariantDarkHighContrast,
scrim = scrimDarkHighContrast,
inverseSurface = inverseSurfaceDarkHighContrast,
inverseOnSurface = inverseOnSurfaceDarkHighContrast,
inversePrimary = inversePrimaryDarkHighContrast,
surfaceDim = surfaceDimDarkHighContrast,
surfaceBright = surfaceBrightDarkHighContrast,
surfaceContainerLowest = surfaceContainerLowestDarkHighContrast,
surfaceContainerLow = surfaceContainerLowDarkHighContrast,
surfaceContainer = surfaceContainerDarkHighContrast,
surfaceContainerHigh = surfaceContainerHighDarkHighContrast,
surfaceContainerHighest = surfaceContainerHighestDarkHighContrast,
)
@Immutable
data class ColorFamily(
val color: Color,
val onColor: Color,
val colorContainer: Color,
val onColorContainer: Color
)
val unspecified_scheme = ColorFamily(
Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified
)
@Composable
fun MapComposeTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable() () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkScheme
else -> lightScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/theme/Type.kt
================================================
package ovh.plrapps.mapcompose.demo.ui.theme
import androidx.compose.material3.Typography
val AppTypography = Typography()
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/widgets/Callout.kt
================================================
package ovh.plrapps.mapcompose.demo.ui.widgets
import android.animation.TimeInterpolator
import android.view.animation.OvershootInterpolator
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.math.RoundingMode
import java.text.DecimalFormat
/**
* A callout which animates its entry with an overshoot scaling interpolator.
*/
@Composable
fun Callout(
x: Double, y: Double,
title: String,
shouldAnimate: Boolean,
onAnimationDone: () -> Unit
) {
var animVal by remember { mutableStateOf(if (shouldAnimate) 0f else 1f) }
LaunchedEffect(true) {
if (shouldAnimate) {
Animatable(0f).animateTo(
targetValue = 1f,
animationSpec = tween(250, easing = overshootEasing)
) {
animVal = value
}
onAnimationDone()
}
}
Surface(
Modifier
.alpha(animVal)
.padding(10.dp)
.graphicsLayer {
scaleX = animVal
scaleY = animVal
transformOrigin = TransformOrigin(0.5f, 1f)
},
shape = RoundedCornerShape(5.dp),
shadowElevation = 10.dp
) {
Column(Modifier.padding(16.dp)) {
Text(
text = title,
modifier = Modifier.align(alignment = Alignment.CenterHorizontally),
fontSize = 14.sp,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
)
Text(
text = "position ${df.format(x)} , ${df.format(y)}",
modifier = Modifier
.align(alignment = Alignment.CenterHorizontally)
.padding(top = 4.dp),
fontSize = 12.sp,
textAlign = TextAlign.Center,
)
}
}
}
private val df = DecimalFormat("#.##").apply {
roundingMode = RoundingMode.CEILING
}
private val overshootEasing = OvershootInterpolator(1.2f).toEasing()
private fun TimeInterpolator.toEasing() = Easing { x ->
getInterpolation(x)
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/ui/widgets/Marker.kt
================================================
package ovh.plrapps.mapcompose.demo.ui.widgets
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import ovh.plrapps.mapcompose.demo.R
@Composable
fun Marker() = Icon(
painter = painterResource(id = R.drawable.map_marker),
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = Color(0xCC2196F3)
)
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/utils/Metrics.kt
================================================
package ovh.plrapps.mapcompose.demo.utils
import android.content.res.Resources
/**
* Convert px to dp
*/
fun pxToDp(px: Int): Int {
return (px / Resources.getSystem().displayMetrics.density).toInt()
}
/**
* Convert dp to px
*/
fun dpToPx(dp: Int): Int {
return (dp * Resources.getSystem().displayMetrics.density).toInt()
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/utils/Random.kt
================================================
package ovh.plrapps.mapcompose.demo.utils
import kotlin.random.Random.Default.nextDouble
fun randomDouble(center: Double, radius: Double) : Double {
return nextDouble(from = center - radius, until = center + radius)
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/utils/WebMercator.kt
================================================
package ovh.plrapps.mapcompose.demo.utils
import kotlin.math.PI
import kotlin.math.atan
import kotlin.math.exp
import kotlin.math.floor
import kotlin.math.ln
import kotlin.math.tan
/**
* Given a [latitude] and [longitude], get the corresponding relative coordinates (values between
* 0.0 and 1.0).
* It is assumed that the map uses the Web Mercator projection.
*/
fun latLonToNormalized(latitude: Double, longitude: Double): Pair {
val latRad = latitude * PI / 180.0
val lngRad = longitude * PI / 180.0
// Web Mercator projected coordinates
val X = earthRadius * lngRad
val Y = earthRadius * ln(tan((PI / 4.0) + (latRad / 2.0)))
// Relative coordinates for MapCompose
val piR = earthRadius * PI
val normalizedX = (X + piR) / (2.0 * piR)
val normalizedY = (piR - Y) / (2.0 * piR)
return Pair(normalizedX, normalizedY)
}
/**
* Given relative coordinates in a world map (a square of size 2.0 * piR), get the corresponding
* latitude and longitude.
* It is assumed that the map uses the Web Mercator projection.
*/
fun normalizedToLatLon(normalizedX: Double, normalizedY: Double): Pair {
val piR = earthRadius * PI
val mercatorX = normalizedX * (2.0 * piR) - piR
val mercatorY = piR - normalizedY * (2.0 * piR)
val num = mercatorX / earthRadius
val num2 = num * 180.0 / PI
val num3 = floor((num2 + 180) / 360.0f)
val lng = num2 - (num3 * 360)
val num4 = PI / 2 - (2.0 * atan(exp(-mercatorY / earthRadius)))
val lat = num4 * 180.0 / PI
return Pair(lat, lng)
}
private const val earthRadius = 6_378_137.0 // in meters
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/AddingMarkerVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import android.app.Application
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import ovh.plrapps.mapcompose.api.*
import ovh.plrapps.mapcompose.demo.R
import ovh.plrapps.mapcompose.demo.providers.makeTileStreamProvider
import ovh.plrapps.mapcompose.ui.state.MapState
class AddingMarkerVM(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider = makeTileStreamProvider(application.applicationContext)
private var markerCount = 0
val state = MapState(4, 8192, 8192) {
scale(0.0) // zoom-out to minimum scale
}.apply {
addLayer(tileStreamProvider)
onMarkerMove { id, x, y, _, _ ->
println("move $id $x $y")
}
onMarkerClick { id, x, y ->
println("marker click $id $x $y")
}
onMarkerLongPress { id, x, y ->
println("on marker long press $id $x $y")
}
onTap { x, y ->
println("on tap $x $y")
}
onLongPress { x, y ->
println("on long press $x $y")
}
enableRotation()
setScrollOffsetRatio(0.5f, 0.5f)
}
fun addMarker() {
state.addMarker("marker$markerCount", 0.5, 0.5) {
Icon(
painter = painterResource(id = R.drawable.map_marker),
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = Color(0xCC2196F3)
)
}
state.enableMarkerDrag("marker$markerCount")
markerCount++
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/AnimationDemoVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import android.app.Application
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.SnapSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.ui.geometry.Offset
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.api.enableRotation
import ovh.plrapps.mapcompose.api.onTouchDown
import ovh.plrapps.mapcompose.api.rotateTo
import ovh.plrapps.mapcompose.api.scrollTo
import ovh.plrapps.mapcompose.api.shouldLoopScale
import ovh.plrapps.mapcompose.demo.providers.makeTileStreamProvider
import ovh.plrapps.mapcompose.demo.ui.widgets.Marker
import ovh.plrapps.mapcompose.ui.state.MapState
/**
* This demo shows how animations can be chained one after another.
* Since animations APIs are suspending functions, this is easy to do.
*/
class AnimationDemoVM(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider = makeTileStreamProvider(application.applicationContext)
private var job: Job? = null
private val spec = TweenSpec(2000, easing = FastOutSlowInEasing)
val state = MapState(4, 8192, 8192).apply {
addLayer(tileStreamProvider)
shouldLoopScale = true
enableRotation()
addMarker("m0", 0.5, 0.5) { Marker() }
addMarker("m1", 0.78, 0.78) { Marker() }
addMarker("m2", 0.79, 0.79) { Marker() }
addMarker("m3", 0.785, 0.72) { Marker() }
onTouchDown {
job?.cancel()
}
viewModelScope.launch {
scrollTo(0.5, 0.5, 2.0, SnapSpec())
}
}
fun startAnimation() {
/* Cancel ongoing animation */
job?.cancel()
/* Start a new one */
with(state) {
job = viewModelScope.launch {
scrollTo(0.0, 0.0, 2.0, spec, screenOffset = Offset.Zero)
scrollTo(0.8, 0.8, 2.0, spec)
rotateTo(180f, spec)
scrollTo(0.5, 0.5, 0.5, spec)
scrollTo(0.5, 0.5, 2.0, TweenSpec(800, easing = FastOutSlowInEasing))
rotateTo(0f, TweenSpec(1000, easing = FastOutSlowInEasing))
}
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/CalloutVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import android.app.Application
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import ovh.plrapps.mapcompose.api.addCallout
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.api.onCalloutClick
import ovh.plrapps.mapcompose.api.onMarkerClick
import ovh.plrapps.mapcompose.api.removeCallout
import ovh.plrapps.mapcompose.api.scale
import ovh.plrapps.mapcompose.demo.R
import ovh.plrapps.mapcompose.demo.providers.makeTileStreamProvider
import ovh.plrapps.mapcompose.demo.ui.widgets.Callout
import ovh.plrapps.mapcompose.ui.state.MapState
class CalloutVM(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider = makeTileStreamProvider(application.applicationContext)
/* Define the markers data (id and position) */
private val markers = listOf(
MarkerInfo("Callout #1", 0.45, 0.6),
MarkerInfo("Callout #2", 0.24, 0.1),
MarkerInfo("Callout #3", 0.25, 0.18),
MarkerInfo(TAP_TO_DISMISS_ID, 0.4, 0.3)
)
val state = MapState(4, 8192, 8192).apply {
addLayer(tileStreamProvider)
/* Add all markers */
for (marker in markers) {
addMarker(marker.id, marker.x, marker.y) {
Icon(
painter = painterResource(id = R.drawable.map_marker),
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = Color(0xCC2196F3)
)
}
}
scale = 0.0
/**
* On marker click, add a callout. If the id is [TAP_TO_DISMISS_ID], set auto-dismiss
* to false. For this particular id, we programmatically remove the callout on tap.
*/
onMarkerClick { id, x, y ->
var shouldAnimate by mutableStateOf(true)
addCallout(
id, x, y,
absoluteOffset = DpOffset(0.dp, (-50).dp),
autoDismiss = id != TAP_TO_DISMISS_ID,
clickable = id == TAP_TO_DISMISS_ID
) {
Callout(x, y, title = id, shouldAnimate) {
shouldAnimate = false
}
}
}
/**
* Register a click listener on callouts. We don't need to remove the other callouts
* because they automatically dismiss on touch down.
*/
onCalloutClick { id, _, _ ->
if (id == TAP_TO_DISMISS_ID) removeCallout(TAP_TO_DISMISS_ID)
}
}
}
private data class MarkerInfo(val id: String, val x: Double, val y: Double)
private const val TAP_TO_DISMISS_ID = "Tap me to dismiss"
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/CenteringOnMarkerVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import android.app.Application
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.api.centerOnMarker
import ovh.plrapps.mapcompose.api.enableRotation
import ovh.plrapps.mapcompose.demo.R
import ovh.plrapps.mapcompose.demo.providers.makeTileStreamProvider
import ovh.plrapps.mapcompose.ui.state.MapState
class CenteringOnMarkerVM(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider = makeTileStreamProvider(application.applicationContext)
val state = MapState(4, 8192, 8192) {
rotation(45f)
}.apply {
addLayer(tileStreamProvider)
addMarker("parking", 0.2457938, 0.3746023) {
Icon(
painter = painterResource(id = R.drawable.map_marker),
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = Color(0xCC2196F3)
)
}
enableRotation()
}
fun onCenter() {
viewModelScope.launch {
state.centerOnMarker("parking", destScale = 1.0, destAngle = 0f)
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/CustomDrawVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import android.app.Application
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.api.enableMarkerDrag
import ovh.plrapps.mapcompose.api.enableRotation
import ovh.plrapps.mapcompose.api.moveMarker
import ovh.plrapps.mapcompose.api.scale
import ovh.plrapps.mapcompose.api.scrollTo
import ovh.plrapps.mapcompose.api.setStateChangeListener
import ovh.plrapps.mapcompose.api.shouldLoopScale
import ovh.plrapps.mapcompose.demo.providers.makeTileStreamProvider
import ovh.plrapps.mapcompose.demo.ui.screens.ScaleIndicatorController
import ovh.plrapps.mapcompose.ui.MapUI
import ovh.plrapps.mapcompose.ui.state.MapState
/**
* In this example, we're adding two markers with custom drag interceptors which update [p1x], [p1y],
* [p2x], and [p2y] states. In turn, when those state change, the line joining the two markers updates.
* The line is added as a custom view inside [MapUI] composable.
*/
class CustomDrawVM(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider = makeTileStreamProvider(application.applicationContext)
var p1x by mutableStateOf(0.6)
var p1y by mutableStateOf(0.6)
var p2x by mutableStateOf(0.4)
var p2y by mutableStateOf(0.4)
val state = MapState(4, 8192, 8192).apply {
addLayer(tileStreamProvider)
shouldLoopScale = true
enableRotation()
viewModelScope.launch {
scrollTo(0.5, 0.5, 1.1)
}
}
val scaleIndicatorController = ScaleIndicatorController(450, state.scale)
init {
state.addMarker("m1", p1x, p1y, Offset(-0.5f, -0.5f)) {
Box(
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.background(Color(0xAAF44336))
)
}
state.addMarker("m2", p2x, p2y, Offset(-0.5f, -0.5f)) {
Box(
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.background(Color(0xAAF44336))
)
}
state.enableMarkerDrag("m1") { id, x, y, dx, dy, _, _ ->
p1x = x + dx
p1y = y + dy
state.moveMarker(id, p1x, p1y)
}
state.enableMarkerDrag("m2") { id, x, y, dx, dy, _, _ ->
p2x = x + dx
p2y = y + dy
state.moveMarker(id, p2x, p2y)
}
state.setStateChangeListener {
scaleIndicatorController.onScaleChanged(scale)
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/HttpTilesVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import androidx.lifecycle.ViewModel
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.scale
import ovh.plrapps.mapcompose.api.shouldLoopScale
import ovh.plrapps.mapcompose.core.TileStreamProvider
import ovh.plrapps.mapcompose.ui.state.MapState
import java.io.BufferedInputStream
import java.net.HttpURLConnection
import java.net.URL
/**
* Shows how MapCompose behaves with remote HTTP tiles.
*/
class HttpTilesVM : ViewModel() {
private val tileStreamProvider = makeTileStreamProvider()
val state = MapState(
levelCount = 4,
fullWidth = 8192,
fullHeight = 8192,
workerCount = 16 // Notice how we increase the worker count when performing HTTP requests
).apply {
addLayer(tileStreamProvider)
scale = 0.0
shouldLoopScale = true
}
}
/**
* A [TileStreamProvider] which performs HTTP requests.
*/
private fun makeTileStreamProvider() =
TileStreamProvider { row, col, zoomLvl ->
try {
val url =
URL("https://raw.githubusercontent.com/p-lr/MapCompose/master/demo/src/main/assets/tiles/mont_blanc_layered/$zoomLvl/$row/$col.jpg")
val connection = url.openConnection() as HttpURLConnection
connection.doInput = true
connection.connect()
BufferedInputStream(connection.inputStream)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/InfiniteScrollVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.enableRotation
import ovh.plrapps.mapcompose.api.shouldLoopScale
import ovh.plrapps.mapcompose.core.TileStreamProvider
import ovh.plrapps.mapcompose.ui.state.MapState
class InfiniteScrollVM(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider = makeWorldTileStreamProvider(application.applicationContext)
val state = MapState(5, 8192, 8192) {
scale(0.1)
infiniteScrollX(true)
}.apply {
addLayer(tileStreamProvider)
shouldLoopScale = true
enableRotation()
}
}
private fun makeWorldTileStreamProvider(appContext: Context) =
TileStreamProvider { row, col, zoomLvl ->
try {
appContext.assets?.open("tiles/world/$zoomLvl/$row/$col.jpg")
} catch (e: Exception) {
null
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/LayersVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.enableRotation
import ovh.plrapps.mapcompose.api.scrollTo
import ovh.plrapps.mapcompose.api.setLayerOpacity
import ovh.plrapps.mapcompose.api.shouldLoopScale
import ovh.plrapps.mapcompose.core.TileStreamProvider
import ovh.plrapps.mapcompose.ui.state.MapState
class LayersVM(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider =
makeTileStreamProvider(application.applicationContext, imageExt = ".jpg")
private val slopesLayerProvider =
makeTileStreamProvider(application.applicationContext, "ign-slopes-", imageExt = ".png")
private val roadLayerProvider =
makeTileStreamProvider(application.applicationContext, "ign-road-", imageExt = ".png")
private var slopesId: String? = null
private var roadId: String? = null
val state = MapState(4, 8192, 8192).apply {
shouldLoopScale = true
enableRotation()
viewModelScope.launch {
scrollTo(0.4, 0.4, 1.0)
}
addLayer(tileStreamProvider)
slopesId = addLayer(slopesLayerProvider, initialOpacity = 0.6f)
roadId = addLayer(roadLayerProvider, initialOpacity = 1f)
}
private fun makeTileStreamProvider(appContext: Context, layer: String = "", imageExt: String) =
TileStreamProvider { row, col, zoomLvl ->
try {
appContext.assets?.open("tiles/mont_blanc_layered/$zoomLvl/$row/$layer$col$imageExt")
} catch (e: Exception) {
null
}
}
fun setSlopesOpacity(opacity: Float) {
slopesId?.also { id ->
state.setLayerOpacity(id, opacity)
}
}
fun setRoadOpacity(opacity: Float) {
roadId?.also { id ->
state.setLayerOpacity(id, opacity)
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/MarkersClusteringVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import android.app.Application
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import ovh.plrapps.mapcompose.api.addClusterer
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.demo.R
import ovh.plrapps.mapcompose.demo.providers.makeTileStreamProvider
import ovh.plrapps.mapcompose.ui.state.MapState
import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy
/**
* Shows how to define and use a marker clusterer.
*/
class MarkersClusteringVM(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider = makeTileStreamProvider(application.applicationContext)
val state = MapState(4, 8192, 8192) {
scale(0.2)
maxScale(8.0)
scroll(0.5, 0.5)
}.apply {
addLayer(tileStreamProvider)
}
init {
/* Add a marker clusterer to manage markers. In this example, we use "default" for the id */
state.addClusterer("default") { ids ->
{ Cluster(size = ids.size) }
}
/* Add some markers to the map, using the same clusterer id we just defined (if a marker
* is added without any clusterer, it won't be managed by any clusterer)*/
listOf(
0.5 to 0.5,
0.51 to 0.5,
0.5 to 0.54,
0.51 to 0.54,
0.6 to 0.52,
0.48 to 0.35,
0.48 to 0.355,
0.485 to 0.35,
0.52 to 0.35,
0.515 to 0.36,
0.515 to 0.355,
).forEachIndexed { i, pair ->
state.addMarker(
id = "marker-$i",
x = pair.first,
y = pair.second,
renderingStrategy = RenderingStrategy.Clustering("default"),
) {
Marker()
}
}
/* We can still add regular markers */
state.addMarker(
"marker-regular", 0.52, 0.36,
clickable = false
) {
Icon(
painter = painterResource(id = R.drawable.map_marker),
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = Color(0xEEF44336)
)
}
}
@Composable
private fun Marker() {
Icon(
painter = painterResource(id = R.drawable.map_marker),
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = Color(0xEE2196F3)
)
}
@Composable
private fun Cluster(size: Int) {
/* Here we can customize the cluster style */
Box(
modifier = Modifier
.background(
Color(0x992196F3),
shape = CircleShape
)
.size(50.dp),
contentAlignment = Alignment.Center
) {
Text(text = size.toString(), color = Color.White)
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/MarkersLazyLoadingVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import android.app.Application
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.addLazyLoader
import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.api.onMarkerClick
import ovh.plrapps.mapcompose.api.shouldLoopScale
import ovh.plrapps.mapcompose.demo.R
import ovh.plrapps.mapcompose.ui.layout.Forced
import ovh.plrapps.mapcompose.ui.state.MapState
import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy
import kotlin.random.Random
/**
* Shows how to define and use a marker lazy-loader.
*/
class MarkersLazyLoadingVM(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider =
ovh.plrapps.mapcompose.demo.providers.makeTileStreamProvider(application.applicationContext)
val state = MapState(4, 8192, 8192) {
minimumScaleMode(Forced(1.0))
scale(1.0)
maxScale(4.0)
scroll(0.5, 0.5)
}.apply {
addLayer(tileStreamProvider)
shouldLoopScale = true
}
init {
/* Add a marker lazy loader. In this example, we use "default" for the id */
state.addLazyLoader("default")
repeat(200) { i ->
val x = Random.nextDouble()
val y = Random.nextDouble()
/* Notice how we set the rendering strategy to lazy loading with the same id */
state.addMarker(
"marker-$i", x, y,
renderingStrategy = RenderingStrategy.LazyLoading(lazyLoaderId = "default")
) {
Icon(
painter = painterResource(id = R.drawable.map_marker),
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = Color(0xEE2196F3)
)
}
}
/* We can still add regular markers */
state.addMarker(
"marker-regular", 0.5, 0.5,
) {
Icon(
painter = painterResource(id = R.drawable.map_marker),
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = Color(0xEEF44336)
)
}
state.onMarkerClick { id, x, y ->
println("marker click $id $x $y")
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/OsmVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.core.TileStreamProvider
import ovh.plrapps.mapcompose.demo.R
import ovh.plrapps.mapcompose.demo.utils.latLonToNormalized
import ovh.plrapps.mapcompose.ui.layout.Forced
import ovh.plrapps.mapcompose.ui.state.MapState
import java.io.BufferedInputStream
import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.pow
/**
* Shows how to use WMTS tile servers with MapCompose, such as Open Street Map.
*/
class OsmVM : ViewModel() {
private val tileStreamProvider = makeTileStreamProvider()
private val maxLevel = 16
private val minLevel = 12
private val mapSize = mapSizeAtLevel(maxLevel, tileSize = 256)
private val paris = latLonToNormalized(48.856667, 2.351667) // Paris
private val x = paris.first
private val y = paris.second
val state = MapState(levelCount = maxLevel + 1, mapSize, mapSize, workerCount = 16) {
minimumScaleMode(Forced(1 / 2.0.pow(maxLevel - minLevel)))
scroll(x, y)
scale(0.0) // to zoom out initially
}.apply {
addLayer(tileStreamProvider)
addMarker("id", x, y) {
Icon(
painter = painterResource(id = R.drawable.map_marker),
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = Color(0xCC2196F3)
)
}
}
}
/**
* A [TileStreamProvider] which performs HTTP requests.
*/
private fun makeTileStreamProvider() =
TileStreamProvider { row, col, zoomLvl ->
try {
val url = URL("https://tile.openstreetmap.org/$zoomLvl/$col/$row.png")
val connection = url.openConnection() as HttpURLConnection
// OSM requires a user-agent
connection.setRequestProperty("User-Agent", "Chrome/120.0.0.0 Safari/537.36")
connection.doInput = true
connection.connect()
BufferedInputStream(connection.inputStream)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/**
* wmts level are 0 based.
* At level 0, the map corresponds to just one tile.
*/
private fun mapSizeAtLevel(wmtsLevel: Int, tileSize: Int): Int {
return tileSize * 2.0.pow(wmtsLevel).toInt()
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/PathsVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import ovh.plrapps.mapcompose.api.addCallout
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.addPath
import ovh.plrapps.mapcompose.api.enableRotation
import ovh.plrapps.mapcompose.api.onPathClick
import ovh.plrapps.mapcompose.api.onPathLongPress
import ovh.plrapps.mapcompose.api.scrollTo
import ovh.plrapps.mapcompose.api.shouldLoopScale
import ovh.plrapps.mapcompose.demo.providers.makeTileStreamProvider
import ovh.plrapps.mapcompose.demo.ui.widgets.Callout
import ovh.plrapps.mapcompose.ui.paths.PathDataBuilder
import ovh.plrapps.mapcompose.ui.paths.model.PatternItem
import ovh.plrapps.mapcompose.ui.paths.model.PatternItem.Dash
import ovh.plrapps.mapcompose.ui.paths.model.PatternItem.Gap
import ovh.plrapps.mapcompose.ui.state.MapState
/**
* In this sample, we add "tracks" to the map. The tracks are rendered as paths using MapCompose.
*/
class PathsVM(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider = makeTileStreamProvider(application.applicationContext)
val state = MapState(4, 8192, 8192).apply {
addLayer(tileStreamProvider)
shouldLoopScale = true
enableRotation()
/**
* Demonstrates path click.
*/
onPathClick { id, x, y ->
var shouldAnimate by mutableStateOf(true)
addCallout(
id, x, y,
absoluteOffset = DpOffset(0.dp, (-10).dp),
) {
Callout(x, y, title = "Click on $id", shouldAnimate) {
shouldAnimate = false
}
}
}
/**
* Demonstrates path long-press.
*/
onPathLongPress { id, x, y ->
var shouldAnimate by mutableStateOf(true)
addCallout(
id, x, y,
absoluteOffset = DpOffset(0.dp, (-10).dp),
) {
Callout(x, y, title = "Long-press on $id", shouldAnimate) {
shouldAnimate = false
}
}
}
viewModelScope.launch {
scrollTo(0.72, 0.3)
}
}
init {
/* Add tracks */
addTrack("track1", Color(0xFF448AFF))
addTrack("track2", Color(0xFFFFFF00))
addTrack("track3", pattern = listOf(Dash(8.dp), Gap(4.dp)))
// filled polygon
with(state) {
addPath(
id = "filled polygon",
color = Color.Green,
fillColor = Color.Green.copy(alpha = .6f),
) {
// Pentagon
addPoint(0.2009, 0.17878)
addPoint(0.08909, 0.2151)
addPoint(0.01999, 0.12)
addPoint(0.08909, 0.02489)
addPoint(0.2009, 0.06122)
}
}
}
/**
* In this sample, we retrieve track points from text files in the assets.
* To add a path, use the [addPath] api. From inside the builder block, you can add individual
* points or a list of points.
* Here, since we're getting points from a sequence, we add them on the fly using [PathDataBuilder.addPoint].
*/
private fun addTrack(
trackName: String,
color: Color? = null,
pattern: List? = null,
clickable: Boolean = true
) {
with(state) {
val lines = getApplication().applicationContext.assets?.open(
"tracks/$trackName.txt"
)?.bufferedReader()?.lineSequence()
?: return@with
addPath(
id = trackName, color = color, clickable = clickable, pattern = pattern, offset = 1
) {
for (line in lines) {
val values = line.split(',').map(String::toDouble)
addPoint(values[0], values[1])
}
}
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/RotationVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.enableRotation
import ovh.plrapps.mapcompose.api.rotateTo
import ovh.plrapps.mapcompose.api.rotation
import ovh.plrapps.mapcompose.api.scale
import ovh.plrapps.mapcompose.api.scroll
import ovh.plrapps.mapcompose.api.setScrollOffsetRatio
import ovh.plrapps.mapcompose.api.setStateChangeListener
import ovh.plrapps.mapcompose.demo.providers.makeTileStreamProvider
import ovh.plrapps.mapcompose.ui.state.MapState
class RotationVM(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider = makeTileStreamProvider(application.applicationContext)
val state = MapState(4, 8192, 8192).apply {
addLayer(tileStreamProvider)
enableRotation()
setScrollOffsetRatio(0.3f, 0.3f)
scale = 0.0
/* Not useful here, just showing how this API works */
setStateChangeListener {
println("scale: $scale, scroll: $scroll, rotation: $rotation")
}
}
fun onRotate() {
viewModelScope.launch {
state.rotateTo(state.rotation + 90f)
}
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/SimpleDemoVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.enableRotation
import ovh.plrapps.mapcompose.api.shouldLoopScale
import ovh.plrapps.mapcompose.demo.providers.makeTileStreamProvider
import ovh.plrapps.mapcompose.ui.state.MapState
class SimpleDemoVM(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider = makeTileStreamProvider(application.applicationContext)
val state = MapState(4, 8448, 8448) {
scale(1.2)
}.apply {
addLayer(tileStreamProvider)
shouldLoopScale = true
enableRotation()
}
}
================================================
FILE: demo/src/main/java/ovh/plrapps/mapcompose/demo/viewmodels/VisibleAreaPaddingVM.kt
================================================
package ovh.plrapps.mapcompose.demo.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.api.enableRotation
import ovh.plrapps.mapcompose.demo.providers.makeTileStreamProvider
import ovh.plrapps.mapcompose.demo.ui.widgets.Marker
import ovh.plrapps.mapcompose.ui.state.MapState
class VisibleAreaPaddingVM(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider = makeTileStreamProvider(application.applicationContext)
val state = MapState(4, 8192, 8192) {
scale(1.2)
}.apply {
enableRotation()
addLayer(tileStreamProvider)
addMarker("m0", 0.5, 0.5) { Marker() }
}
}
================================================
FILE: demo/src/main/res/drawable/ic_launcher_background.xml
================================================
================================================
FILE: demo/src/main/res/drawable/map_marker.xml
================================================
================================================
FILE: demo/src/main/res/drawable-v24/ic_launcher_foreground.xml
================================================
================================================
FILE: demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
================================================
================================================
FILE: demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
================================================
================================================
FILE: demo/src/main/res/values/colors.xml
================================================
#FFBB86FC
#FF6200EE
#FF3700B3
#FF03DAC5
#FF018786
#FF000000
#FFFFFFFF
================================================
FILE: demo/src/main/res/values/strings.xml
================================================
MapCompose Demo
================================================
FILE: demo/src/main/res/values/themes.xml
================================================
================================================
FILE: demo/src/test/java/ovh/plrapps/mapcompose/demo/utils/WebMercatorTest.kt
================================================
package ovh.plrapps.mapcompose.demo.utils
import org.junit.Assert.assertEquals
import org.junit.Test
class WebMercatorTest {
@Test
fun latLonToNormalizedTest() {
val lat = 48.856667
val lon = 2.351667
val parisNormalized = latLonToNormalized(lat, lon)
val parisLonLat = normalizedToLatLon(parisNormalized.first, parisNormalized.second)
assertEquals(lat, parisLonLat.first, 0.00001)
assertEquals(lon, parisLonLat.second, 0.00001)
}
}
================================================
FILE: doc/mapcompose/MapCompose.drawio
================================================
7V1rc5s4F/41/hgPkpAQH3Pt7jt76Uy7k+1HbGSbKQEvJo7TX/+KizC62BYO2E7TdKYxAoQ5z7nrHGWEbp82n7JgufgzDVk8gk64GaG7EYTA9zz+qxh5rUccD1Uj8ywK67HtwJfoBxMX1qPPUchW0oV5msZ5tJQHp2mSsGkujQVZlr7Il83SWH7qMpgzbeDLNIjF6Bhvxx+jMF/U44D42xO/sWi+qB9OIalOTILp93mWPif1E0cQzcqf6vRTIOaqX3W1CML0pTWE7kfoNkvTvPr0tLllcUFeQThxX/4qvu0I3Szyp5gfAP6xPP2w42ZgczN/uYwleftxu+Z7fly+/ID0Ifj99Z//km9f4Q2dXcFqlnUQP4uHQBLz+W5mKZ+2/Xjy33MqTlytSj645hcAvNxsT/JP8+L3n8Hyn9/FVPw7VbNV57T3KiFgYf1eL4soZ1+WwbQ4+8K5Vn7tWRTHt2mcZuW9KAwYnU35+CrP0u+sdYZMKZvMjISqSbtmWc42raGacJ9Y+sTy7JVfUp+lfg2lkBJaH7+0GE7AvWjxGqnHgprL583UW6T4hxqsDsCh4YD7kgc5E7NNsi1sA6DZBzZUxgY5WMfGPSU2dCBsvkYxuw2SdbCSILIHxDkNIIqwYOfcwgJdDZGbKH8Klp+5pTJxukJB/uK5TKaMcbCCSXlBQdVlGiV5+bXxzQjf8ZHgOU8rQMsbgjiaJ/xzzGbFVAU1I27EruvhPC1wWHFYomT+tTi4u3JrvqhtLnD7gccHMjwe1OHxDejAwdAhBnlREGBJeF04C/xoGgerVTSV8ai0vzD/0EA5TrDs9d8CLu4w1Iff6rvLg7tNjWV19Fofdbc3bBPlrQfxo29iZv55+5jiQDxllQdZrr9hOfwQxQelc5U+Z1O2h8igFgE+4Zzlh5UXCyW3S+emFrdgA7eIsYzFQR6tZRfOxEL1Ez4XgtRiVk9mVgIULqxevL6r7f0oEwFEnDEggLM24t+OCiMhjAbyxxRRz/GJ57nAR0h+TEU27TElvzdEeYMIeIdFoJMaV7k/DFaLxib3oEQAwDIwrkHJY6MaoXAoPaLb3dJipnHMAw8upm/zO/vXxAC5iuti61YiPBANgcl3Uch2gFAynzYxk6NpaYM3T8ofmdrQbZnPrKKBon2TNGE9GUcKZH2DPR0RAyCqWuoPEN110Y1jHPNom53FuSPIQCATy9IeKAQDHDggDCFzAZpNOHVM8etHYldB/YZd4ZnZFX9wQPClAWLhWfzUgAiLJgDxzg2I/8EB8S4MEGGsPiwgqg05OyDggwOi2pCzA/LLy5IB8Q1h2kkBMWX/PxIgqoScGxDXIkorVhqX9i/frJjWWeZRe83RmE1wqDuWs1pYTNWmC2pWTE+S3XUttHm31FYPC4D7QbTmQ6JT1xT6DkZbZJGtsU4OyNkwNd2lUde7vyb3ziDUbdMTnJJVLdTqOyTnpmHNsWFl9KQENmUHpJXRaUOR7eIn8n3HKdLL6npoK7OrLuF9Z1nCjEt7dmum1jAHq2VV1TKLNgXaKu6ysprRKZtObfhhQrGLzfzQPc8MlSVy19cZgQ6UszMyArYwCmewly4xeNontpciYXT59hJ3ZsRz20v8jhR8B+qeS53jPtcmLoecl2MvsXlF8ws3OYW+WwRJaeQUkuvVKiIum3IaMU5ArfzkKQrDeBdIsrZpI2OycFUBJbqZZ0EY8eeZzvVh04DjqNGOq8Pl+6eEy5Ta7Vj4BZabyk0x1H61MbdzYy6DFXqBG6hwu64e9JucmMHgJn1HtruV3DFCpqLTewkIVmpzXIP87bDvg0GiW6TrA6Ufx+CCCPJRaMaFUgPt+yC36sRjH1nxP4JDEVuP5h7TjEdexauWhZXvn+hYKXdwHXpmolsUSw4eObmiKqWp8PV1yXdOqot7L6AbKmpqALRmQh6lEughj7gQU4yFJ9v2csCYez8ehhQDj1tKeErKv6McZAfat+V7jAh2gM+dM9chVGTeTkNdi2qBd0jdpjhjTJDrU8enlJszoOcHzkl74d5clqo1eL2nVbXeu1nQaQD8SVStqAN4D8qgA+0vRdzfUXKwO2dfuKq1yCROn7N1Q9Fu/UAmDLb9QKNWN1CrOWhHP9C2u8cvo46mv2eMPDja1+Ozay1o99pPcftnlkWcyEWu5m7vetCFdOwAhP2xw/kNkvIzlM0XZzMPUoAxQpwZRYKzczePC+jYAS4mCDjE87FcU4gB/wp8mADAWdqlSrfEjmYezlDBa+uyuqdvz5uqjY51bedWEqopt3LRQ6uQp0fdv3LCO5wll+rR+kkzwp4erf8CawuWmsAnBn/rpHBZZBF6NkOq84sZDY0uAoUTVBbdNYar6Vy1M1w7MTvYEiryupdiYUrl7wAMAeU6ntsRmYuwM4bE91wAkMf1v6L8OzSMumPPI8T3K2smN4ZhxP0l6CBKuVeFgTuYiVHsZ10eMKyJsUk6bDm/ruu0ZnswkrqxHQcrjAzhIR+sOFIdI+GYAckpEy3YVi6ZhfRZNmpz8tRHbxM9cmHOHXYKz8r3iOdyKSSOkpzgoudAxH9TzrlY7VK1Fj3MRY9g7rxBzy+epgi464/FE7iFF8XZJ2rVphbpjzcKxxGBwpE2oQlm5ECG0L1SYwpL+pWLg9sZXJxcEG+83VuAuIpccIOF3db+AsfKBeDyJR6BRQzfDnpaJ8FpxUKP4JvFQH0nq06d9xrbQ5/AhwddUCDwyQMyCVQ6nBvbLMPuqSH0TLkUbzeTvW33oT3LsvsytT8dElhYp7MhoUd/DRJ/fSgkDP05p0XCFNh1rMyipi25xuPx8bVYxvaoNn710JDRfx9wE2VHqcIN1EuBTFuwDRbJU5tV6TftKSVFMVdFGINk34vAQ1tL7Q1jnLHX3j5KRDVmj0xKSvS4Q+JBP6wm8+Fdpchl+WsOUjZBO3pfKUcpyyLqLnZ9pQKA8pUpkPb7HCYVQPVUQLVzHh97iLnsdNVybbbbxaiXru+AL7v3nqPvgXPSSlRhXg3mLYzWRutW4HRVE7owb+W+hJp5s+nOYqT4t/ve1TJIxNj9psh5z4tKZsFGJHgqUEsmq2XrJk6G9n2muSYsf2GMHzpPQVT8yhcZC8KCvknx/4twtaYpZ4k84qYU3pYg5Qt+XCijzTKOplFp/l+T6SJLk+gHVytpcuBraKa+GS7JfaQDUNQ4lj+6aDTjgy4EyB5h88x6V8o/yp0r70hfQqQkVD1k2rbV0HlFetj7bDlJ/maf/P9h/xpErwGk0fWPK5tulgMuQyut6WIlrdksOh/lDUgJGrg/q7nPG7BIJh3vDRDbtKboHLoUdwAqpexYrVC3XzWA8kwuHsgdaDZcbPIO/boDRhGx2N3roIiYd0etxKNTynFX0hMeynp2FwlJoo6XD9F/+M42YQWOqwV4x3rLu/aG7ls81K/s0hOIRw8bGe/aprgwKG8xIMOuih0vErYBJLp0kfB7EwnXTiT6YlqLmgdbnX4mJrsUnoBajyJGxy71aN1e6kw9csWG/Lter8P14+fbT/P8j8e//7q+6rbK2cHS709k6Tk3I8TH6Rpoq2vOyUVU8esQOlKxUGWHbm03j4E5CF4KB+00qQfsqT3n2flz5+Iov6iXkHlKjaFteUqfylXZ82iu4ofbP6VUXb79k1Xo/v8=
================================================
FILE: gradle/gradle-daemon-jvm.properties
================================================
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/0b98aec810298c2c1d7fdac5dac37910/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/658299a896470fbb3103ba3a430ee227/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/23adb857f3cb3cbe28750bc7faa7abc0/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/932015f6361ccaead0c6d9b8717ed96e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21
================================================
FILE: gradle/wrapper/gradle-wrapper.properties
================================================
#Wed Apr 21 20:01:30 CEST 2021
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
================================================
FILE: gradle.properties
================================================
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx8096M -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=false
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.nonTransitiveRClass=false
android.nonFinalResIds=false
android.defaults.buildfeatures.resvalues=true
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.enableAppCompileTimeRClass=false
android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false
android.builtInKotlin=false
android.newDsl=false
================================================
FILE: gradlew
================================================
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"
================================================
FILE: gradlew.bat
================================================
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
================================================
FILE: mapcompose/build.gradle
================================================
import com.vanniktech.maven.publish.AndroidSingleVariantLibrary
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id 'com.android.library'
id 'kotlin-android'
id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version"
id "com.vanniktech.maven.publish" version "0.36.0"
}
android {
compileSdk = 36
defaultConfig {
minSdk = 21
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
namespace = 'ovh.plrapps.mapcompose'
lint {
targetSdk 36
}
testOptions {
targetSdk 36
}
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll([
'-Xopt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi',
'-Xopt-in=androidx.compose.foundation.ExperimentalFoundationApi',
])
jvmTarget = JvmTarget.JVM_17
}
}
dependencies {
// Compose - See https://developer.android.com/jetpack/compose/setup#bom-version-mapping
api platform('androidx.compose:compose-bom:2026.04.01')
api "androidx.compose.foundation:foundation"
implementation "androidx.compose.ui:ui-tooling-preview"
debugImplementation "androidx.compose.ui:ui-tooling"
implementation "androidx.compose.ui:ui-util"
implementation "androidx.compose.ui:ui-unit"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutine_version"
testImplementation 'junit:junit:4.13.2'
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutine_version"
testImplementation 'org.robolectric:robolectric:4.16'
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4"
}
ext.'signing.keyId' = System.getenv('signingKeyId')
ext.'signing.password' = System.getenv('signingPwd')
ext.'signing.secretKeyRingFile' = System.getenv('signingKeyFile')
mavenPublishing {
configure(new AndroidSingleVariantLibrary("release", true, true))
coordinates(GROUP, ARTIFACT_ID, VERSION_NAME)
pom {
name = POM_NAME
description = POM_DESCRIPTION
url = POM_URL
scm {
url = POM_SCM_URL
connection = POM_SCM_CONNECTION
developerConnection = POM_SCM_DEV_CONNECTION
}
licenses {
license {
name = POM_LICENCE_NAME
url = POM_LICENCE_URL
distribution = POM_LICENCE_DIST
}
}
developers {
developer {
id = POM_DEVELOPER_ID
name = POM_DEVELOPER_NAME
url = POM_DEVELOPER_URL
}
}
}
publishToMavenCentral(false)
signAllPublications()
}
================================================
FILE: mapcompose/gradle.properties
================================================
GROUP=ovh.plrapps
VERSION_NAME=3.2.7
ARTIFACT_ID=mapcompose
POM_NAME=MapCompose
POM_ARTIFACT_ID=library
POM_DESCRIPTION=A Jetpack Compose Android library to display tiled maps, with support for markers, paths, and rotation
POM_PACKAGING=aar
POM_INCEPTION_YEAR=2021
POM_URL=https://github.com/p-lr/MapCompose
POM_SCM_URL=https://github.com/p-lr/MapCompose
POM_SCM_CONNECTION=scm:git@github.com:p-lr/MapCompose.git
POM_SCM_DEV_CONNECTION=scm:git@github.com:p-lr/MapCompose.git
POM_LICENCE_NAME=The Apache Software License, Version 2.0
POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt
POM_LICENCE_DIST=repo
POM_DEVELOPER_ID=p-lr
POM_DEVELOPER_NAME=Pierre Laurence
POM_DEVELOPER_URL=https://github.com/p-lr/
================================================
FILE: mapcompose/src/main/AndroidManifest.xml
================================================
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/api/Annotation.kt
================================================
package ovh.plrapps.mapcompose.api
@RequiresOptIn(message = "This API is experimental. It is likely to change before becoming stable.")
@Retention(AnnotationRetention.BINARY)
@Target(
AnnotationTarget.CLASS,
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY,
AnnotationTarget.PROPERTY_GETTER,
)
annotation class ExperimentalClusteringApi
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/api/ApiDefaults.kt
================================================
package ovh.plrapps.mapcompose.api
internal const val maxAnimationsRetries = 6
internal const val animationsRetriesInterval = 10L
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/api/Common.kt
================================================
package ovh.plrapps.mapcompose.api
import androidx.compose.ui.unit.IntOffset
import ovh.plrapps.mapcompose.ui.state.VisibleAreaPadding
import ovh.plrapps.mapcompose.utils.AngleDegree
import ovh.plrapps.mapcompose.utils.rotateX
import ovh.plrapps.mapcompose.utils.rotateY
import ovh.plrapps.mapcompose.utils.toRad
/**
* When scrolling to a given position, the viewport needs to be offset by taking into account the
* [VisibleAreaPadding]. This is needed for apis when scrolling is involved.
*/
internal fun VisibleAreaPadding.getOffsetForScroll(rotation: AngleDegree): IntOffset {
val angle = -rotation.toRad()
val offsetX = rotateX((left - right) / 2.0, (top - bottom) / 2.0, angle)
val offsetY = rotateY((left - right) / 2.0, (top - bottom) / 2.0, angle)
return IntOffset(offsetX.toInt(), offsetY.toInt())
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/api/DefaultCanvas.kt
================================================
package ovh.plrapps.mapcompose.api
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.withTransform
import ovh.plrapps.mapcompose.ui.MapUI
import ovh.plrapps.mapcompose.ui.state.MapState
/**
* A custom canvas which moves, scales, and rotates along with the map (exactly like some internal
* components of [MapUI]).
* It's an example which can be used in a custom composable (as it takes a [drawBlock] as input).
*
* This implementation only uses the public API. Therefore, different implementations are possible.
* However, this implementation should fit most needs for custom drawings inside [MapUI].
*/
@Composable
fun DefaultCanvas(
modifier: Modifier,
mapState: MapState,
drawBlock: DrawScope.() -> Unit
) {
Canvas(
modifier = modifier
) {
withTransform({
/* Geometric transformations seem to be applied in reversed order of declaration */
translate(left = -mapState.scroll.x.toFloat(), top = -mapState.scroll.y.toFloat())
rotate(
degrees = mapState.rotation,
pivot = Offset(
x = (mapState.centroidX * mapState.fullSize.width * mapState.scale).toFloat(),
y = (mapState.centroidY.toFloat() * mapState.fullSize.height * mapState.scale).toFloat()
)
)
scale(scale = mapState.scale.toFloat(), Offset.Zero)
}, drawBlock)
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/api/GesturesApi.kt
================================================
@file:Suppress("unused")
package ovh.plrapps.mapcompose.api
import androidx.compose.ui.platform.ViewConfiguration
import ovh.plrapps.mapcompose.ui.state.MapState
/**
* Enable rotation by user gestures.
*/
fun MapState.enableRotation() {
zoomPanRotateState.isRotationEnabled = true
}
/**
* Enable scrolling by user gestures. This is enabled by default.
*/
fun MapState.enableScrolling() {
zoomPanRotateState.isScrollingEnabled = true
}
/**
* Enable zooming by user gestures. This is enabled by default.
*/
fun MapState.enableZooming() {
zoomPanRotateState.isZoomingEnabled = true
}
/**
* Discard rotation gestures. The map can still be programmatically rotated using APIs such as
* [rotateTo] or [rotation].
*/
fun MapState.disableRotation() {
zoomPanRotateState.isRotationEnabled = false
}
/**
* Discard scrolling gestures. The map can still be programmatically scrolled using APIs such as
* [scrollTo] or [snapScrollTo].
*/
fun MapState.disableScrolling() {
zoomPanRotateState.isScrollingEnabled = false
}
/**
* Discard zooming gestures. The map can still be programmatically zoomed using [scale].
*/
fun MapState.disableZooming() {
zoomPanRotateState.isZoomingEnabled = false
}
/**
* Disable gesture detection. The map view can still be transformed programmatically.
*/
fun MapState.disableGestures() {
with (zoomPanRotateState) {
isRotationEnabled = false
isScrollingEnabled = false
isZoomingEnabled = false
}
}
/**
* Enables fling scale animation after a pinch to zoom gesture. Enabled by default.
*/
fun MapState.enableFlingZoom() {
zoomPanRotateState.isFlingZoomEnabled = true
}
/**
* Disables fling scale animation after a pinch to zoom gesture.
*/
fun MapState.disableFlingZoom() {
zoomPanRotateState.isFlingZoomEnabled = false
}
/**
* Registers a tap callback for tap gestures. The callback is invoked with the relative coordinates
* of the tapped point on the map.
* Note: the tap gesture is detected only after the [ViewConfiguration.doubleTapMinTimeMillis] has
* passed, because the layout's gesture detector also detects double-tap gestures.
*/
fun MapState.onTap(tapCb: (x: Double, y: Double) -> Unit) {
this.tapCb = tapCb
}
/**
* Registers a callback for long press gestures. The callback is invoked with the relative coordinates
* of the pressed point on the map.
*/
fun MapState.onLongPress(longPressCb: (x: Double, y: Double) -> Unit) {
this.longPressCb = longPressCb
}
/**
* Registers a callback for touch down event.
*/
fun MapState.onTouchDown(cb: () -> Unit) {
touchDownCb = cb
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/api/LayerApi.kt
================================================
@file:Suppress("unused")
package ovh.plrapps.mapcompose.api
import ovh.plrapps.mapcompose.core.*
import ovh.plrapps.mapcompose.ui.state.MapState
import java.util.*
/**
* Add a layer. By default, the layer is added on top of the layer stack (see [AboveAll]).
* Optionally, the layer can be added at the bottom of the stack, or above / below an existing layer.
*
* Note that [initialOpacity] is taken into account _only_ if the layer being added isn't the lowest
* one, or the only one. However, if later on another layer is added below this layer, the
* [initialOpacity] will be taken into account.
*
* @return The id of the created layer
*/
fun MapState.addLayer(
tileStreamProvider: TileStreamProvider,
initialOpacity: Float = 1f,
placement: LayerPlacement = AboveAll
): String {
val layers = tileCanvasState.layerFlow.value.toMutableList()
val id = makeLayerId()
val layer = Layer(id, tileStreamProvider, initialOpacity)
val newLayers = when (placement) {
AboveAll -> {
layers + layer
}
is AboveLayer -> {
val existingLayerIndex = layers.indexOfFirst { it.id == placement.layerId }
if (existingLayerIndex != -1 && existingLayerIndex < layers.lastIndex) {
layers.add(existingLayerIndex + 1, layer)
}
layers
}
BelowAll -> {
layers.add(0, layer)
layers
}
is BelowLayer -> {
val existingLayerIndex = layers.indexOfFirst { it.id == placement.layerId }
if (existingLayerIndex != -1) {
layers.add(existingLayerIndex, layer)
}
layers
}
}
setLayers(newLayers)
return id
}
/**
* Replaces a layer. If the layer doesn't exist, no layer is added.
*
* @return The id of the added layer, or null if [layerId] doesn't match with any existing layer
*/
fun MapState.replaceLayer(
layerId: String,
tileStreamProvider: TileStreamProvider,
initialOpacity: Float = 1f
): String? {
val layers = tileCanvasState.layerFlow.value.toMutableList()
val index = layers.indexOfFirst {
it.id == layerId
}
val id = makeLayerId()
return if (index != -1) {
layers[index] = Layer(id, tileStreamProvider, initialOpacity)
setLayers(layers)
id
} else null
}
/**
* Moves a layer up in the layer stack, making it drawn on top of the layer which was previously
* above it.
*/
fun MapState.moveLayerUp(layerId: String) {
val layers = tileCanvasState.layerFlow.value.toMutableList()
val index = layers.indexOfFirst {
it.id == layerId
}
if (index < layers.lastIndex) {
Collections.swap(layers, index + 1, index)
setLayers(layers)
}
}
/**
* Moves a layer down in the layer stack, making it drawn below the layer which was previously
* below it.
*/
fun MapState.moveLayerDown(layerId: String) {
val layers = tileCanvasState.layerFlow.value.toMutableList()
val index = layers.indexOfFirst {
it.id == layerId
}
if (index > 0) {
Collections.swap(layers, index - 1, index)
setLayers(layers)
}
}
/**
* Remove the top layer from the stack.
*/
fun MapState.removeLastLayer() {
val layers = tileCanvasState.layerFlow.value.toMutableList()
val remainingLayers = layers.subList(0, layers.size - 1)
setLayers(remainingLayers)
}
/**
* Remove the top [n] layers from the stack.
* @param n The number of layers to remove.
*/
fun MapState.removeLastLayers(n: Int) {
val layers = tileCanvasState.layerFlow.value.toMutableList()
val remainingLayers = layers.subList(0, (layers.size - n).coerceAtLeast(0))
setLayers(remainingLayers)
}
/**
* Reorder layers in the order of the provided list of ids. Layers listed first will be drawn before
* subsequent layers (so the later will be above).
* Existing layers not included in the provided list will be removed
*/
fun MapState.reorderLayers(layerIds: List) {
val layerForId = tileCanvasState.layerFlow.value.associateBy { it.id }
val layers = layerIds.mapNotNull { layerForId[it] }
setLayers(layers)
}
/**
* Remove all layers.
*/
fun MapState.removeAllLayers() {
setLayers(emptyList())
}
/**
* Remove some layers.
*/
fun MapState.removeLayers(layerIds: List) {
val remainingLayers = tileCanvasState.layerFlow.value.filterNot {
it.id in layerIds
}
setLayers(remainingLayers)
}
/**
* Remove a layer.
*/
fun MapState.removeLayer(layerId: String) {
val remainingLayers = tileCanvasState.layerFlow.value.filterNot {
it.id == layerId
}
setLayers(remainingLayers)
}
/**
* Dynamically update the opacity of a layer. If the layer is the lowest one or the only one, the
* new opacity won't have effect until a layer is added below it.
*/
fun MapState.setLayerOpacity(layerId: String, opacity: Float) {
val newLayers = tileCanvasState.layerFlow.value.map {
if (it.id == layerId) {
it.copy(alpha = opacity.coerceIn(0f..1f))
} else it
}
setLayers(newLayers)
}
/**
* Define the list of layers using a builder.
*
* @return The list of layer ids, in the order of addition.
*/
fun MapState.buildLayers(builder: LayersBuilder.() -> Unit): List {
val builderInternal = LayersBuilderInternal()
builderInternal.apply(builder)
setLayers(builderInternal.layers)
return builderInternal.layers.map { it.id }
}
interface LayersBuilder {
fun addLayer(tileStreamProvider: TileStreamProvider, initialOpacity: Float = 1f)
}
/**
* Utility function to automatically refresh tiles after a change of layers.
*/
private fun MapState.setLayers(layers: List) {
tileCanvasState.setLayers(layers)
renderVisibleTilesThrottled()
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/api/LayoutApi.kt
================================================
@file:Suppress("unused")
package ovh.plrapps.mapcompose.api
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ovh.plrapps.mapcompose.ui.layout.Fill
import ovh.plrapps.mapcompose.ui.layout.Fit
import ovh.plrapps.mapcompose.ui.layout.Forced
import ovh.plrapps.mapcompose.ui.layout.MinimumScaleMode
import ovh.plrapps.mapcompose.ui.state.MapState
import ovh.plrapps.mapcompose.ui.state.VisibleAreaPadding
import ovh.plrapps.mapcompose.ui.state.ZoomPanRotateState
import ovh.plrapps.mapcompose.utils.*
import kotlin.math.min
import kotlin.math.roundToInt
/**
* The scale of the map. By convention, the scale at full dimension is 1.0.
*/
var MapState.scale: Double
get() = zoomPanRotateState.scale
set(value) {
zoomPanRotateState.setScale(value)
}
/**
* The [rotation] property is the angle (in decimal degrees) of rotation,
* using the center of the view as the pivot point.
*/
var MapState.rotation: AngleDegree
get() = zoomPanRotateState.rotation
set(value) {
zoomPanRotateState.setRotation(value)
}
/**
* Get the current [scroll] - the position of the top-left corner of the visible viewport.
* This is a low-level concept (returned value is in scaled pixels).
*/
val MapState.scroll: Scroll
get() = Scroll(zoomPanRotateState.scrollX, zoomPanRotateState.scrollY)
/**
* Set the [scroll] - the position of the top-left corner of the visible viewport. This is a
* suspending call because it's required to wait the first composition. Otherwise, it's invoked
* immediately.
* This is a low-level concept (input value is expected to be in scaled pixels). To scroll to a
* known position, prefer the [snapScrollTo] API.
*/
suspend fun MapState.setScroll(scrollX: Double, scrollY: Double) {
with(zoomPanRotateState) {
awaitLayout()
setScroll(scrollX, scrollY)
}
}
fun MapState.referentialSnapshotFlow(): Flow = snapshotFlow {
ReferentialSnapshot(zoomPanRotateState.scale, scroll, zoomPanRotateState.rotation)
}
data class ReferentialSnapshot(val scale: Double, val scroll: Scroll, val rotation: AngleDegree)
/**
* Get notified whenever the state ([scale] and/or [scroll] and/or [rotation]) changes.
*
* @param cb An extension function with [MapState] as receiver type
*/
fun MapState.setStateChangeListener(cb: MapState.() -> Unit) {
stateChangeListener = cb
}
/**
* Removes the state change listener.
*/
fun MapState.removeStateChangeListener() {
stateChangeListener = null
}
/**
* On double-tap, and if the scale is already at its maximum value, circle-back to the minimum scale.
*/
var MapState.shouldLoopScale
get() = zoomPanRotateState.shouldLoopScale
set(value) {
zoomPanRotateState.shouldLoopScale = value
}
/**
* Sets the padding of the visible area of the map viewport in [Dp], for the purpose of camera moves.
* For example, if you have some UI obscuring the map on the left, you can set the appropriate
* left padding. Then, when you use the scrollTo methods, the map will take that into account, by
* centering on the visible portion of the viewport.
*/
fun MapState.setVisibleAreaPadding(
left: Dp = 0.dp,
right: Dp = 0.dp,
top: Dp = 0.dp,
bottom: Dp = 0.dp
) {
setVisibleAreaPadding(
left = dpToPx(left.value).roundToInt(),
right = dpToPx(right.value).roundToInt(),
top = dpToPx(top.value).roundToInt(),
bottom = dpToPx(bottom.value).roundToInt()
)
}
/**
* Variant of [MapState.setVisibleAreaPadding] using ratios. This is a suspending call because it
* awaits for the first layout.
*
* @param leftRatio The left padding will be equal to this ratio multiplied by the layout width.
* @param rightRatio The right padding will be equal to this ratio multiplied by the layout width.
* @param topRatio The top padding will be equal to this ratio multiplied by the layout height.
* @param bottomRatio The bottom padding will be equal to this ratio multiplied by the layout height.
*/
suspend fun MapState.setVisibleAreaPadding(
leftRatio: Float = 0f,
rightRatio: Float = 0f,
topRatio: Float = 0f,
bottomRatio: Float = 0f
) {
with(zoomPanRotateState) {
awaitLayout()
val layoutSize = zoomPanRotateState.layoutSize
setVisibleAreaPadding(
left = (leftRatio * layoutSize.width).roundToInt(),
right = (rightRatio * layoutSize.width).roundToInt(),
top = (topRatio * layoutSize.height).roundToInt(),
bottom = (bottomRatio * layoutSize.height).roundToInt()
)
}
}
/**
* Variant of [MapState.setVisibleAreaPadding] using pixels.
*/
fun MapState.setVisibleAreaPadding(left: Int = 0, right: Int = 0, top: Int = 0, bottom: Int = 0) {
zoomPanRotateState.visibleAreaPadding = VisibleAreaPadding(left, top, right, bottom)
}
/**
* Set the minimum scale mode. See [MinimumScaleMode].
* The minimum scale can be manually defined using [Forced], or can be inferred using [Fill], or
* [Fit] (the default).
* Note: When enabling map rotation, it's advised to use the [Fill] mode.
*/
var MapState.minimumScaleMode: MinimumScaleMode
get() = zoomPanRotateState.minimumScaleMode
set(value) {
zoomPanRotateState.minimumScaleMode = value
}
/**
* Get the current minimum scale. The minimum scale changes on [minimumScaleMode] change.
* Do note that the initial value is always 0.0. However, the value is updated after the first layout
* pass. To observe minimum scale changes, use [MapState.minScaleSnapshotFlow] api.
*/
val MapState.minScale: Double
get() = zoomPanRotateState.minScale
/**
* Get the minimum scale changes.
*/
fun MapState.minScaleSnapshotFlow(): Flow {
return snapshotFlow {
zoomPanRotateState.minScale
}
}
/**
* The default maximum scale is 2.0.
* When changed, and if the current scale is greater than the new [maxScale], the current scale is
* changed to be equal to [maxScale].
*/
var MapState.maxScale: Double
get() = zoomPanRotateState.maxScale
set(value) {
zoomPanRotateState.maxScale = value
}
/**
* The scroll offset ratio allows to scroll past the default scroll limits. They are expressed in
* percent of the layout dimensions.
* Setting a scroll offset ratio is useful when rotation is enabled, so that edges of the map are
* reachable.
* The recommended value to try it out is 0.5f
* Values must be in [0f..1f] range, or an [IllegalArgumentException] is thrown.
*
* This parameter has no effect in x dimension when infinite scroll is enabled.
*
* @param xRatio The horizontal scroll offset ratio. The scroll offset will be equal to this ratio
* multiplied by the layout width.
* @param yRatio The vertical scroll offset ratio. The scroll offset will be equal to this ratio
* multiplied by the layout height.
*/
fun MapState.setScrollOffsetRatio(xRatio: Float, yRatio: Float) {
zoomPanRotateState.scrollOffsetRatio = Offset(xRatio, yRatio)
}
/**
* Rotates to the specified [angle] in decimal degrees, animating the rotation.
*/
suspend fun MapState.rotateTo(
angle: AngleDegree,
animationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow)
) {
withRetry(maxAnimationsRetries, animationsRetriesInterval) {
zoomPanRotateState.smoothRotateTo(angle, animationSpec)
}
}
/**
* Get the layout dimensions in pixels.
* Note that layout dimension may change during the lifetime of the application. The returned value
* is a read-only snapshot.
*/
suspend fun MapState.getLayoutSize(): IntSize {
return with(zoomPanRotateState) {
awaitLayout()
layoutSize
}
}
/**
* Get the layout dimensions in pixels, as a [Flow].
* This api is useful to observe layout changes.
*/
suspend fun MapState.getLayoutSizeFlow(): Flow {
return with(zoomPanRotateState) {
awaitLayout()
snapshotFlow {
layoutSize
}
}
}
/**
* Scrolls to a position. Defaults to centering on the provided scroll destination.
*
* @param x The normalized X position on the map, in range [0..1]
* @param y The normalized Y position on the map, in range [0..1]
* @param screenOffset Offset of the screen relatively to its dimension. Default is
* Offset(-0.5f, -0.5f), so moving the screen by half the width left and by half the height top,
* effectively centering on the scroll destination.
*/
suspend fun MapState.snapScrollTo(
x: Double,
y: Double,
screenOffset: Offset = Offset(-0.5f, -0.5f)
) {
with(zoomPanRotateState) {
awaitLayout()
val offsetX = screenOffset.x * layoutSize.width
val offsetY = screenOffset.y * layoutSize.height
val paddingOffset = visibleAreaPadding.getOffsetForScroll(rotation)
val destScrollX = x * fullWidth * scale + offsetX - paddingOffset.x
val destScrollY = y * fullHeight * scale + offsetY - paddingOffset.y
setScroll(destScrollX, destScrollY)
}
}
/**
* Scrolls to a position, animating the scroll and the scale. Defaults to centering on the provided
* scroll destination.
*
* @param x The normalized X position on the map, in range [0..1]
* @param y The normalized Y position on the map, in range [0..1]
* @param destScale The destination scale. The default value is the current scale.
* @param animationSpec The [AnimationSpec]. Default is [SpringSpec] with low stiffness.
* @param screenOffset Offset of the screen relatively to its dimension. Default is
* Offset(-0.5f, -0.5f), so moving the screen by half the width left and by half the height top,
* effectively centering on the scroll destination.
*/
suspend fun MapState.scrollTo(
x: Double,
y: Double,
destScale: Double = scale,
animationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow),
screenOffset: Offset = Offset(-0.5f, -0.5f)
) {
with(zoomPanRotateState) {
awaitLayout()
val offsetX = screenOffset.x * layoutSize.width
val offsetY = screenOffset.y * layoutSize.height
val effectiveDstScale = constrainScale(destScale)
val paddingOffset = visibleAreaPadding.getOffsetForScroll(rotation)
val destScrollX = x * fullWidth * effectiveDstScale + offsetX - paddingOffset.x
val destScrollY = y * fullHeight * effectiveDstScale + offsetY - paddingOffset.y
withRetry(maxAnimationsRetries, animationsRetriesInterval) {
smoothScrollScaleRotate(
destScrollX,
destScrollY,
effectiveDstScale,
animationSpec
)
}
}
}
/**
* Scrolls to an area. The target position will be centered on the area, scaled in as much as
* possible while still keeping the area plus the provided padding completely in view.
*
* @param area The [BoundingBox] of the target area to scroll to.
* @param padding Padding around the area defined as a fraction of the viewport.
*/
suspend fun MapState.snapScrollTo(
area: BoundingBox,
padding: Offset = Offset(0f, 0f)
) {
with(zoomPanRotateState) {
awaitLayout()
val (center, scale) = calculateScrollTo(area, padding)
setScale(scale)
snapScrollTo(center.x, center.y)
}
}
/**
* Scrolls to an area, animating the scroll and the scale. The target position will be centered
* on the area, scaled in as much as possible while still keeping the area plus the provided
* padding completely in view.
*
* @param area The [BoundingBox] of the target area to scroll to.
* @param padding Padding around the area defined as a fraction of the viewport.
* @param animationSpec The [AnimationSpec]. Default is [SpringSpec] with low stiffness.
*/
suspend fun MapState.scrollTo(
area: BoundingBox,
padding: Offset = Offset(0f, 0f),
animationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow),
) {
with(zoomPanRotateState) {
awaitLayout()
val (center, scale) = calculateScrollTo(area, padding)
scrollTo(center.x, center.y, scale, animationSpec)
}
}
/**
* Calculates the target scroll position and scale that will be centered on the given [area] and
* scaled in as much as possible while keeping the [area] plus [padding] in view.
*
* @return The scroll position and scale, as a [Pair].
*/
private fun ZoomPanRotateState.calculateScrollTo(
area: BoundingBox,
padding: Offset
): Pair {
val centerX = (area.xLeft + area.xRight) / 2
val centerY = (area.yTop + area.yBottom) / 2
val xAxisScale = fullHeight / fullWidth.toDouble()
val normalizedArea = area.scaleAxis(1 / xAxisScale)
val rotatedNormalizedArea =
normalizedArea.rotate(Point(centerX / xAxisScale, centerY), -rotation.toRad())
val rotatedArea = rotatedNormalizedArea.scaleAxis(xAxisScale)
val areaWidth = fullWidth * (rotatedArea.xRight - rotatedArea.xLeft)
val availableViewportWidth = (layoutSize.width - visibleAreaPadding.left - visibleAreaPadding.right) * (1 - padding.x)
val horizontalScale = availableViewportWidth / areaWidth
val areaHeight = fullHeight * (rotatedArea.yBottom - rotatedArea.yTop)
val availableViewportHeight = (layoutSize.height - visibleAreaPadding.top - visibleAreaPadding.bottom) * (1 - padding.y)
val verticalScale = availableViewportHeight / areaHeight
val targetScale = min(horizontalScale, verticalScale)
val effectiveTargetScale = constrainScale(targetScale)
return Point(centerX, centerY) to effectiveTargetScale
}
/**
* The [centroidX] is the x coordinate of the center of the current viewport (which is also the
* origin of rotation transformation). It changes with the scroll and the scale.
* This is a low-level concept, and is only useful when defining custom views.
* The value is a relative coordinate (in [0.0 .. 1.0] range).
*/
val MapState.centroidX: Double
get() = zoomPanRotateState.centroidX
/**
* The [centroidY] is the y coordinate of the center of the current viewport (which is also the
* origin of rotation transformation). It changes with the scroll and the scale.
* This is a low-level concept, and is only useful when defining custom views.
* The value is a relative coordinate (in [0.0 .. 1.0] range).
*/
val MapState.centroidY: Double
get() = zoomPanRotateState.centroidY
/**
* Get the flow of centroid points. A centroid point contains the normalized coordinates of the
* center of the map.
* Useful for asynchronous processing using flow operators. Like every snapshot flow, it should be
* collected from the main thread.
*
* Example:
* ```
* mapState.centroidSnapshotFlow().map { point ->
* withContext(Dispatchers.Default) {
* // some heavy computing
* }
* }.launchIn(scope) // scope is using Dispatchers.Main
* ```
*/
fun MapState.centroidSnapshotFlow(): Flow {
return snapshotFlow {
Point(zoomPanRotateState.centroidX, zoomPanRotateState.centroidY)
}
}
/**
* A convenience property. It corresponds to the size used when creating the [MapState].
*/
val MapState.fullSize: IntSize
get() = IntSize(zoomPanRotateState.fullWidth, zoomPanRotateState.fullHeight)
/**
* Returns the level, an entire value belonging to [0 ; levelCount - 1], where `levelCount` is the
* count of levels passed at [MapState] constructor.
*/
fun MapState.getLevelAtScale(scale: Double): Int {
return visibleTilesResolver.getLevel(scale)
}
/**
* Stops all currently running animations. If other animations are scheduled to run (inside running
* coroutines), you might have to cancel those coroutines as well.
*/
suspend fun MapState.stopAnimations() {
zoomPanRotateState.stopAnimations()
}
/**
* Returns the visible area expressed in normalized coordinates. This does not account for rotation.
* When the map isn't rotated, the obtained [BoundingBox] represents the same area as the one
* obtained with the [visibleArea] API.
*/
suspend fun MapState.visibleBoundingBox(): BoundingBox {
return with(zoomPanRotateState) {
awaitLayout()
BoundingBox(
xLeft = centroidX - layoutSize.width / (2 * fullWidth * scale),
yTop = centroidY - layoutSize.height / (2 * fullHeight * scale),
xRight = centroidX + layoutSize.width / (2 * fullWidth * scale),
yBottom = centroidY + layoutSize.height / (2 * fullHeight * scale)
)
}
}
data class BoundingBox(val xLeft: Double, val yTop: Double, val xRight: Double, val yBottom: Double)
/**
* Returns the visible area expressed in normalized coordinates. This **does** account for rotation.
*
* @return The [VisibleArea], as follows:
* ```
* p1 p2
* ---------
* | |
* | |
* ---------
* p4 p3
* ```
*/
suspend fun MapState.visibleArea(padding: IntOffset = IntOffset.Zero): VisibleArea {
return with(zoomPanRotateState) {
awaitLayout()
val xLeft = centroidX - (layoutSize.width + padding.x * 2) / (2 * fullWidth * scale)
val yTop = centroidY - (layoutSize.height + padding.y * 2) / (2 * fullHeight * scale)
val xRight = centroidX + (layoutSize.width + padding.x * 2) / (2 * fullWidth * scale)
val yBottom = centroidY + (layoutSize.height + padding.y * 2) / (2 * fullHeight * scale)
val xAxisScale = fullHeight / fullWidth.toDouble()
val scaledCenterX = centroidX / xAxisScale
val p1x = rotateCenteredX(
xLeft / xAxisScale, yTop, scaledCenterX, centroidY, -rotation.toRad()
) * xAxisScale
val p1y = rotateCenteredY(
xLeft / xAxisScale, yTop, scaledCenterX, centroidY, -rotation.toRad()
)
val p2x = rotateCenteredX(
xRight / xAxisScale, yTop, scaledCenterX, centroidY, -rotation.toRad()
) * xAxisScale
val p2y = rotateCenteredY(
xRight / xAxisScale, yTop, scaledCenterX, centroidY, -rotation.toRad()
)
val p3x = rotateCenteredX(
xRight / xAxisScale, yBottom, scaledCenterX, centroidY, -rotation.toRad()
) * xAxisScale
val p3y = rotateCenteredY(
xRight / xAxisScale, yBottom, scaledCenterX, centroidY, -rotation.toRad()
)
val p4x = rotateCenteredX(
xLeft / xAxisScale, yBottom, scaledCenterX, centroidY, -rotation.toRad()
) * xAxisScale
val p4y = rotateCenteredY(
xLeft / xAxisScale, yBottom, scaledCenterX, centroidY, -rotation.toRad()
)
visibleAreaMutex.withLock {
val area = visibleArea
if (area == null) {
visibleArea = VisibleArea(p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y)
} else {
area._p1x = p1x
area._p1y = p1y
area._p2x = p2x
area._p2y = p2y
area._p3x = p3x
area._p3y = p3y
area._p4x = p4x
area._p4y = p4y
}
visibleArea as VisibleArea
}
}
}
/**
* Returns the visible area expressed in normalized coordinates. This *does* account for rotation.
*/
suspend fun MapState.visibleAreaFlow(
padding: IntOffset = IntOffset.Zero,
throttleMillis: Long = 500
): Flow {
return snapshotFlow {
centroidX.hashCode() + centroidY.hashCode() + scale.hashCode()
}.throttle(throttleMillis).map {
visibleArea(padding)
}
}
/**
* ```
* p1 p2
* ---------
* | |
* | |
* ---------
* p4 p3
* ```
*/
data class VisibleArea(
internal var _p1x: Double,
internal var _p1y: Double,
internal var _p2x: Double,
internal var _p2y: Double,
internal var _p3x: Double,
internal var _p3y: Double,
internal var _p4x: Double,
internal var _p4y: Double,
) {
val p1x: Double
get() = _p1x
val p1y: Double
get() = _p1y
val p2x: Double
get() = _p2x
val p2y: Double
get() = _p2y
val p3x: Double
get() = _p3x
val p3y: Double
get() = _p3y
val p4x: Double
get() = _p4x
val p4y: Double
get() = _p4y
}
/* Internally, we're working on a single VisibleArea instance, and we must ensure mutual exclusion
* when creating the instance. */
internal val visibleAreaMutex = Mutex()
internal var visibleArea: VisibleArea? = null
/**
* Returns a flow which emits [MapState] whenever the viewport changes.
* It's a flow equivalent of [setStateChangeListener].
*/
fun MapState.viewportChangeFlow(): Flow {
return snapshotFlow {
centroidX.hashCode() + centroidY.hashCode() + scale.hashCode() + rotation.hashCode()
}.map { this }
}
/**
* The [MapState] is considered idle when its [centroidX] and [centroidY] haven't changed for at
* least [thresholdMillis] which is 400ms by default.
*/
fun MapState.idleStateFlow(thresholdMillis: Long = 400): StateFlow {
val stateFlow = MutableStateFlow(false)
scope.launch {
snapshotFlow {
"$centroidX,$centroidY"
}.map {
stateFlow.value = false
}.collectLatest {
delay(thresholdMillis)
stateFlow.value = true
}
}
return stateFlow.asStateFlow()
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/api/MarkerApi.kt
================================================
@file:Suppress("unused")
package ovh.plrapps.mapcompose.api
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import kotlinx.coroutines.flow.Flow
import ovh.plrapps.mapcompose.ui.state.MapState
import ovh.plrapps.mapcompose.ui.state.markers.DragEndListener
import ovh.plrapps.mapcompose.ui.state.markers.DragInterceptor
import ovh.plrapps.mapcompose.ui.state.markers.DragStartListener
import ovh.plrapps.mapcompose.ui.state.markers.model.MarkerData
import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy
import ovh.plrapps.mapcompose.utils.AngleDegree
import ovh.plrapps.mapcompose.utils.rotateX
import ovh.plrapps.mapcompose.utils.rotateY
import ovh.plrapps.mapcompose.utils.toRad
import ovh.plrapps.mapcompose.utils.withRetry
/**
* Add a marker to the given position.
*
* @param id The id of the marker
* @param x The normalized X position on the map, in range [0..1]
* @param y The normalized Y position on the map, in range [0..1]
* @param relativeOffset The x-axis and y-axis positions of the marker will be respectively offset by
* the width of the marker multiplied by the x value of the offset, and the height of the marker
* multiplied by the y value of the offset.
* @param absoluteOffset The x-axis and y-axis positions of a marker will be respectively offset by
* the x and y [Dp] values of the offset.
* @param zIndex A marker with larger zIndex will be drawn on top of all markers with smaller zIndex.
* When markers have the same zIndex, the original order in which the parent placed the marker is used.
* @param clickable Controls whether the marker is clickable. Default is true. If a click listener
* is registered using [onMarkerClick], that listener will be invoked for that marker if [clickable]
* is true.
* @param isConstrainedInBounds By default, a marker cannot be positioned or moved outside of the
* map bounds.
* @param clickableAreaScale The clickable area, which defaults to the bounds of the provided
* composable, can be expanded or shrunk. For example, using Offset(1.2f, 1f), the clickable area
* will be expanded by 20% on the X axis relatively to the center.
* @param clickableAreaCenterOffset The center of the clickable area will be offset by the width of
* the marker multiplied by the x value of the offset, and the height of the marker multiplied by
* the y value of the offset.
* @param renderingStrategy By default, markers are eagerly laid-out, e.g they are laid-out
* even when not visible. There are two alternative rendering strategies:
* - [RenderingStrategy.LazyLoading]: removes all non-visible markers, dynamically.
* - [RenderingStrategy.Clustering]: in addition to lazy loading, clusterize markers when they are
* close to each other.
*/
fun MapState.addMarker(
id: String,
x: Double,
y: Double,
relativeOffset: Offset = Offset(-0.5f, -1f),
absoluteOffset: DpOffset = DpOffset.Zero,
zIndex: Float = 0f,
clickable: Boolean = true,
isConstrainedInBounds: Boolean = true,
clickableAreaScale: Offset = Offset(1f, 1f),
clickableAreaCenterOffset: Offset = Offset(0f, 0f),
renderingStrategy: RenderingStrategy = RenderingStrategy.Default,
c: @Composable () -> Unit
) {
markerState.addMarker(
id,
x,
y,
relativeOffset,
absoluteOffset,
zIndex,
clickable,
isConstrainedInBounds,
clickableAreaScale,
clickableAreaCenterOffset,
renderingStrategy,
c
)
}
/**
* Add a clusterer which will clusterize all markers added with the same clusterer id.
* The default behavior on cluster click is a zoom-in to reveal the content of the clicked
* cluster. This can be changed using [clusterClickBehavior].
* The style of a cluster is user-defined using [clusterFactory].
*
* @param id The id of the clusterer.
* @param clusteringThreshold When the distance between two markers goes below that threshold, a
* cluster is formed. Defaults to 50 dp. There's one exception: when the scale reaches max scale,
* in which case clustering is disabled.
* @param clusterClickBehavior Defines the behavior when a cluster is clicked.
* @param scaleThreshold Defines the scale above which the clusterer is disabled. Defaults to
* [ClusterScaleThreshold.MaxScale] which corresponds to [MapState.maxScale].
* @param clusterFactory Compose code for a cluster. Receives the list of marker ids which are fused
* to form the cluster.
*/
fun MapState.addClusterer(
id: String,
clusteringThreshold: Dp = 50.dp,
clusterClickBehavior: ClusterClickBehavior = Default,
scaleThreshold: ClusterScaleThreshold = ClusterScaleThreshold.MaxScale,
clusterFactory: (ids: List) -> (@Composable () -> Unit)
) {
markerState.addClusterer(
mapState = this,
id = id,
clusteringThreshold = clusteringThreshold,
clusterClickBehavior = clusterClickBehavior.toInternal(),
scaleThreshold = scaleThreshold,
clusterFactory = clusterFactory
)
}
/**
* Set a list of marker id to not clusterize by the clusterer which has the given [id].
* This is useful to call this api right at the beginning of a marker drag. Otherwise, the clusterer
* might clusterize the marker during the drag gesture which would cause the gesture to be interrupted
* and the `onDragEnd` callback (if set) wouldn't be invoked.
* When this api is invoked, the relevant clusterer re-processes its managed markers.
*
* @param id The id of the clusterer
* @param markersToExempt The set of marker ids to not clusterize.
*/
fun MapState.setClustererExemptList(
id: String,
markersToExempt: Set
) {
markerState.setClusteredExemptList(id, markersToExempt)
}
/**
* Add a lazy loader for markers. The lazy loader removes markers as they go out of the visible area
* (and adds markers which are visible).
*
* @param id The id for the lazy loader
* @param padding Padding added to the visible area, in dp. Defaults to 0.
*/
fun MapState.addLazyLoader(
id: String,
padding: Dp = 0.dp
) {
markerState.addLazyLoader(this, id, padding)
}
/**
* Remove a clusterer.
* By default, also removes all markers managed by this clusterer.
*/
fun MapState.removeClusterer(
id: String,
removeManagedMarkers: Boolean = true
) {
markerState.removeClusterer(id, removeManagedMarkers)
}
/**
* Remove a lazy loader.
* By default, also removes all markers managed by this lazy loader.
*/
fun MapState.removeLazyLoader(
id: String,
removeManagedMarkers: Boolean = true
) {
markerState.removeLazyLoader(id, removeManagedMarkers)
}
/**
* Check whether a marker was already added or not.
*/
fun MapState.hasMarker(id: String): Boolean {
return markerState.hasMarker(id)
}
/**
* Get info on a marker, if the marker is already added.
*
* @return Available [MarkerInfo] if the marker was already added, `null` otherwise.
*/
fun MapState.getMarkerInfo(id: String): MarkerInfo? {
return markerState.getMarker(id)?.let {
MarkerInfo(it.id, it.x, it.y, it.relativeOffset, it.absoluteOffset, it.zIndex)
}
}
/**
* Updates the [zIndex] for an existing marker.
*
* @param id The id of the marker
* @param zIndex A marker with larger zIndex will be drawn on top of all markers with smaller zIndex.
* When markers have the same zIndex, the original order in which the parent placed the marker is used.
*/
fun MapState.updateMarkerZ(
id: String,
zIndex: Float
) {
markerState.getMarker(id)?.zIndex = zIndex
}
/**
* Updates the clickable property of an existing marker.
*
* @param id The id of the marker
* @param clickable Controls whether the marker is clickable.
*/
fun MapState.updateMarkerClickable(
id: String,
clickable: Boolean
) {
markerState.getMarker(id)?.isClickable = clickable
}
/**
* Updates the offsets of a marker.
* @param relativeOffset The x-axis and y-axis positions of the marker will be respectively offset by
* the width of the marker multiplied by the x value of the offset, and the height of the marker
* multiplied by the y value of the offset. If null, does not updates the current value.
* @param absoluteOffset The x-axis and y-axis positions of a marker will be respectively offset by
* the x and y [Dp] values of the offset. If null, does not updates the current value.
* @param animationSpec The [AnimationSpec]. Default is [SpringSpec] with low stiffness. When null,
* no animation is used.
*/
suspend fun MapState.updateMarkerOffset(
id: String,
relativeOffset: Offset? = null,
absoluteOffset: DpOffset? = null,
animationSpec: AnimationSpec? = SpringSpec(stiffness = Spring.StiffnessLow)
) {
updateOffset(
markerData = markerState.getMarker(id) ?: return,
relativeOffset = relativeOffset,
absoluteOffset = absoluteOffset,
animationSpec = animationSpec
)
}
/**
* Updates the constrained in bounds state of the marker.
*
* @param id The id of the marker
* @param constrainedInBounds Controls whether the marker is constrained inside map bounds
*/
fun MapState.updateMarkerConstrained(
id: String,
constrainedInBounds: Boolean
) {
val markerData = markerState.getMarker(id) ?: return
markerData.isConstrainedInBounds = constrainedInBounds
/* If constrained, immediately move the marker to its constrained position */
if (constrainedInBounds) {
markerState.moveMarkerTo(id, markerData.x, markerData.y)
}
}
/**
* Updates the clickable area of the marker.
*/
fun MapState.updateClickableArea(
id: String,
clickableAreaScale: Offset? = null,
clickableAreaCenterOffset: Offset? = null
) {
val markerData = markerState.getMarker(id) ?: return
if (clickableAreaScale != null) {
markerData.clickableAreaScale = clickableAreaScale
}
if (clickableAreaCenterOffset != null) {
markerData.clickableAreaCenterOffset = clickableAreaCenterOffset
}
}
/**
* Update a marker visibility.
*
* @param id The id of the marker
*/
fun MapState.updateMarkerVisibility(id: String, visible: Boolean) {
val markerData = markerState.getMarker(id) ?: return
markerData.isVisible = visible
}
/**
* Remove a marker.
*
* @param id The id of the marker
*/
fun MapState.removeMarker(id: String): Boolean {
return markerState.removeMarker(id)
}
/**
* Remove all markers.
*/
fun MapState.removeAllMarkers() {
markerState.removeAllMarkers()
}
/**
* Move marker to the given position.
*
* @param id The id of the marker
* @param x The normalized X position on the map, in range [0..1]
* @param y The normalized Y position on the map, in range [0..1]
*/
fun MapState.moveMarker(id: String, x: Double, y: Double) {
markerState.moveMarkerTo(id, x, y)
}
/**
* Enable drag gestures on a marker.
*
* @param id The id of the marker
* @param onDragStart (Optional) To get notified when a drag gesture starts.
* @param onDragEnd (Optional) To get notified when a drag gesture ends.
* @param dragInterceptor (Optional) Useful to constrain drag movements along a path. When this
* parameter is set, you're responsible for invoking [moveMarker] with appropriate values (using
* your own custom logic).
* See [DragInterceptor].
*/
fun MapState.enableMarkerDrag(
id: String,
onDragStart: DragStartListener? = null,
onDragEnd: DragEndListener? = null,
dragInterceptor: DragInterceptor? = null
) {
markerState.setDraggable(id, true)
val markerData = markerState.getMarker(id)
if (onDragStart != null) {
markerData?.dragStartListener = onDragStart
}
if (onDragEnd != null) {
markerData?.dragEndListener = onDragEnd
}
if (dragInterceptor != null) {
markerData?.dragInterceptor = dragInterceptor
}
}
/**
* Disable drag gestures on a marker.
*
* @param id The id of the marker
*/
fun MapState.disableMarkerDrag(id: String) {
markerState.setDraggable(id, false)
}
/**
* Register a callback which will be invoked for every marker move (API move and user drag).
*/
fun MapState.onMarkerMove(
cb: (id: String, x: Double, y: Double, dx: Double, dy: Double) -> Unit
) {
markerState.markerMoveCb = cb
}
/**
* Register a callback which will be invoked when a marker is tapped.
* Beware that this click listener will only be invoked if the marker is clickable, and when the
* click gesture isn't already consumed by some other composable (like a button).
*/
fun MapState.onMarkerClick(cb: (id: String, x: Double, y: Double) -> Unit) {
markerState.markerClickCb = cb
}
/**
* Register a callback which will be invoked when a marker is long-pressed.
* Beware that the provided callback will only be invoked if the marker is clickable, and when the
* gesture isn't already consumed by some other composable (like a button).
*/
fun MapState.onMarkerLongPress(cb: (id: String, x: Double, y: Double) -> Unit) {
markerState.markerLongPressCb = cb
}
/**
* Sometimes, some components need to observe marker position changes. However, the [MapState] owns
* the [State] of each marker position. To avoid duplicating state and have the [MapState] as single
* source of truth, this API creates an observable [State] of marker positions.
* Note that this api only accounts for regular markers (e.g not managed by a clusterer).
*/
fun MapState.markerDerivedState(): State> {
return derivedStateOf {
markerState.getRenderedMarkers().map {
MarkerDataSnapshot(it.id, it.x, it.y)
}
}
}
/**
* Similar to [markerDerivedState], but useful for asynchronous processing, using flow operators.
* Like every snapshot flow, it should be collected from the main thread.
* Note that this api only accounts for regular markers (e.g not managed by a clusterer).
*/
fun MapState.markerSnapshotFlow(): Flow> {
return snapshotFlow {
markerState.getRenderedMarkers().map {
MarkerDataSnapshot(it.id, it.x, it.y)
}
}
}
data class MarkerDataSnapshot(val id: String, val x: Double, val y: Double)
/**
* Register a callback which will be invoked when a callout is tapped.
* Beware that this click listener will only be invoked if the callout is clickable, and when the
* click gesture isn't already consumed by some other composable (like a button).
*/
fun MapState.onCalloutClick(cb: (id: String, x: Double, y: Double) -> Unit) {
markerRenderState.calloutClickCb = cb
}
/**
* Move a marker, given a displacement in pixels. This is typically useful when programmatically
* simulating a drag gesture.
* This API is internally used when enabling drag gestures on a marker using [enableMarkerDrag].
*
* @param id The id of the marker
* @param deltaPx The displacement amount in pixels
*/
fun MapState.moveMarkerBy(id: String, deltaPx: Offset) {
val angle = -zoomPanRotateState.rotation.toRad()
val dx = rotateX(deltaPx.x.toDouble(), deltaPx.y.toDouble(), angle)
val dy = rotateY(deltaPx.x.toDouble(), deltaPx.y.toDouble(), angle)
markerState.moveMarkerBy(
id,
dx / (zoomPanRotateState.fullWidth * zoomPanRotateState.scale),
dy / (zoomPanRotateState.fullHeight * zoomPanRotateState.scale)
)
}
/**
* Center on a marker, animating the scroll.
*
* @param id The id of the marker
* @param animationSpec The [AnimationSpec]. Default is [SpringSpec] with low stiffness.
*/
suspend fun MapState.centerOnMarker(
id: String,
animationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow)
) {
with(zoomPanRotateState) {
markerState.getMarker(id)?.also {
awaitLayout()
val paddingOffset = visibleAreaPadding.getOffsetForScroll(rotation)
val destScrollX = it.x * fullWidth * scale - layoutSize.width / 2 - paddingOffset.x
val destScrollY = it.y * fullHeight * scale - layoutSize.height / 2 - paddingOffset.y
withRetry(maxAnimationsRetries, animationsRetriesInterval) {
smoothScrollTo(destScrollX, destScrollY, animationSpec)
}
}
}
}
/**
* Center on a marker, animating the scroll position and the scale.
*
* @param id The id of the marker
* @param destScale The destination scale
* @param animationSpec The [AnimationSpec]. Default is [SpringSpec] with low stiffness.
*/
suspend fun MapState.centerOnMarker(
id: String,
destScale: Double,
animationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow)
) {
with(zoomPanRotateState) {
markerState.getMarker(id)?.also {
awaitLayout()
val destScaleCst = constrainScale(destScale)
val paddingOffset = visibleAreaPadding.getOffsetForScroll(rotation)
val destScrollX = it.x * fullWidth * destScaleCst - layoutSize.width / 2 - paddingOffset.x
val destScrollY = it.y * fullHeight * destScaleCst - layoutSize.height / 2 - paddingOffset.y
withRetry(maxAnimationsRetries, animationsRetriesInterval) {
smoothScrollScaleRotate(
destScrollX,
destScrollY,
destScale,
animationSpec
)
}
}
}
}
/**
* Center on a marker, animating the scroll position, the scale, and the rotation.
*
* @param id The id of the marker
* @param destScale The destination scale
* @param destAngle The destination angle in decimal degrees
* @param animationSpec The [AnimationSpec]. Default is [SpringSpec] with low stiffness.
*/
suspend fun MapState.centerOnMarker(
id: String,
destScale: Double,
destAngle: AngleDegree,
animationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow)
) {
with(zoomPanRotateState) {
markerState.getMarker(id)?.also {
awaitLayout()
val destScaleCst = constrainScale(destScale)
val paddingOffset = visibleAreaPadding.getOffsetForScroll(rotation)
val destScrollX = it.x * fullWidth * destScaleCst - layoutSize.width / 2 - paddingOffset.x
val destScrollY = it.y * fullHeight * destScaleCst - layoutSize.height / 2 - paddingOffset.y
withRetry(maxAnimationsRetries, animationsRetriesInterval) {
smoothScrollScaleRotate(
destScrollX,
destScrollY,
destScale,
destAngle,
animationSpec
)
}
}
}
}
/**
* Add a callout to the given position.
*
* @param id The id of the callout
* @param x The normalized X position on the map, in range [0..1]
* @param y The normalized Y position on the map, in range [0..1]
* @param relativeOffset The x-axis and y-axis positions of the callout will be respectively offset by
* the width of the marker multiplied by the x value of the offset, and the height of the marker
* multiplied by the y value of the offset.
* @param absoluteOffset The x-axis and y-axis positions of a callout will be respectively offset by
* the x and y [Dp] values of the offset.
* @param zIndex A callout with larger zIndex will be drawn on top of all callouts with smaller zIndex.
* When callouts have the same zIndex, the original order in which the parent placed the callout is used.
* @param autoDismiss Whether the callout should be dismissed on touch down. Default is true. If set
* to false, the callout can be programmatically dismissed with [removeCallout].
* @param clickable Controls whether the callout is clickable. Default is false. If a click listener
* is registered using [onMarkerClick], that listener will only be invoked for that marker if
* [clickable] is true.
* @param isConstrainedInBounds By default, a callout cannot be positioned outside of the map
* bounds.
*/
fun MapState.addCallout(
id: String,
x: Double,
y: Double,
relativeOffset: Offset = Offset(-0.5f, -1f),
absoluteOffset: DpOffset = DpOffset.Zero,
zIndex: Float = 0f,
autoDismiss: Boolean = true,
clickable: Boolean = false,
isConstrainedInBounds: Boolean = true,
c: @Composable () -> Unit
) {
markerRenderState.addCallout(
id,
x,
y,
relativeOffset,
absoluteOffset,
zIndex,
autoDismiss,
clickable,
isConstrainedInBounds,
c
)
}
/**
* Check whether a callout was already added or not.
*/
fun MapState.hasCallout(id: String): Boolean {
return markerRenderState.hasCallout(id)
}
/**
* Updates the clickable property of an existing callout.
*
* @param id The id of the marker
* @param clickable Controls whether the callout is clickable.
*/
fun MapState.updateCalloutClickable(
id: String,
clickable: Boolean
) {
markerRenderState.callouts[id]?.markerData?.isClickable = clickable
}
/**
* Moves a callout.
*
* @param id The id of the callout.
* @param x The normalized X position on the map, in range [0..1]
* @param y The normalized Y position on the map, in range [0..1]
*/
fun MapState.moveCallout(id: String, x: Double, y: Double) {
markerRenderState.moveCallout(id, x, y)
}
/**
* Removes a callout.
*
* @param id The id of the callout.
*/
fun MapState.removeCallout(id: String): Boolean {
return markerRenderState.removeCallout(id)
}
/**
* Updates the offsets of a callout.
* @param relativeOffset The x-axis and y-axis positions of the callout will be respectively offset by
* the width of the callout multiplied by the x value of the offset, and the height of the callout
* multiplied by the y value of the offset. If null, does not updates the current value.
* @param absoluteOffset The x-axis and y-axis positions of a callout will be respectively offset by
* the x and y [Dp] values of the offset. If null, does not updates the current value.
* @param animationSpec The [AnimationSpec]. Default is [SpringSpec] with low stiffness. When null,
* no animation is used.
*/
suspend fun MapState.updateCalloutOffset(
id: String,
relativeOffset: Offset? = null,
absoluteOffset: DpOffset? = null,
animationSpec: AnimationSpec? = SpringSpec(stiffness = Spring.StiffnessLow)
) {
updateOffset(
markerData = markerRenderState.callouts[id]?.markerData ?: return,
relativeOffset = relativeOffset,
absoluteOffset = absoluteOffset,
animationSpec = animationSpec
)
}
private suspend fun MapState.updateOffset(
markerData: MarkerData,
relativeOffset: Offset?,
absoluteOffset: DpOffset?,
animationSpec: AnimationSpec?
) {
if (animationSpec != null) {
with(zoomPanRotateState) {
awaitLayout()
invokeAndCheckSuccess {
Animatable(0f).animateTo(1f, animationSpec) {
if (relativeOffset != null) {
markerData.relativeOffset = lerp(
markerData.relativeOffset,
relativeOffset,
value
)
}
if (absoluteOffset != null) {
markerData.absoluteOffset = lerp(
markerData.absoluteOffset,
absoluteOffset,
value
)
}
}
}
}
} else {
if (relativeOffset != null) {
markerData.relativeOffset = relativeOffset
}
if (absoluteOffset != null) {
markerData.absoluteOffset = absoluteOffset
}
}
}
/**
* Public data on a marker.
*/
data class MarkerInfo(
val id: String, val x: Double,
val y: Double,
val relativeOffset: Offset,
val absoluteOffset: DpOffset,
val zIndex: Float
)
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/api/Model.kt
================================================
package ovh.plrapps.mapcompose.api
import ovh.plrapps.mapcompose.core.Layer
import ovh.plrapps.mapcompose.core.TileStreamProvider
import ovh.plrapps.mapcompose.core.makeLayerId
import ovh.plrapps.mapcompose.ui.state.markers.model.ClusterClickBehavior as ClusterClickBehaviorInternal
import ovh.plrapps.mapcompose.ui.state.markers.model.Custom as CustomInternal
import ovh.plrapps.mapcompose.ui.state.markers.model.Default as DefaultInternal
import ovh.plrapps.mapcompose.ui.state.markers.model.None as NoneInternal
data class Scroll(val x: Double, val y: Double)
sealed interface ClusterClickBehavior
/**
* Zoom-in to reveal a subset or all markers of the cluster.
*/
data object Default : ClusterClickBehavior
/**
* When a cluster is clicked, the provided [onClick] callback is invoked.
* the optional parameter [withDefaultBehavior] signifies if the [Default] callback behavior should be applied too
*/
data class Custom(val withDefaultBehavior: Boolean = false, val onClick: (ClusterData) -> Unit) : ClusterClickBehavior
/**
* Cluster related data.
* @param x, y The coordinates of the cluster's barycenter
* @param markers The list of markers contained by the cluster
*/
data class ClusterData(val x: Double, val y: Double, val markers: List)
/**
* Clusters aren't clickable
*/
data object None : ClusterClickBehavior
/**
* Convert public api type to internal type.
*/
internal fun ClusterClickBehavior.toInternal(): ClusterClickBehaviorInternal {
return when (this) {
is Custom -> CustomInternal(
onClick = {
this.onClick(
ClusterData(
it.x,
it.y,
it.markers.map { markerData ->
MarkerDataSnapshot(markerData.id, markerData.x, markerData.y)
}
)
)
},
withDefaultBehavior = this.withDefaultBehavior
)
Default -> DefaultInternal
None -> NoneInternal
}
}
sealed interface ClusterScaleThreshold {
data object MaxScale : ClusterScaleThreshold
data class FixedScale(val scale: Double) : ClusterScaleThreshold
}
internal class LayersBuilderInternal : LayersBuilder {
internal val layers = mutableListOf()
override fun addLayer(tileStreamProvider: TileStreamProvider, initialOpacity: Float) {
val id = makeLayerId()
val layer = Layer(id, tileStreamProvider, initialOpacity)
layers.add(layer)
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/api/PathApi.kt
================================================
@file:Suppress("unused")
package ovh.plrapps.mapcompose.api
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import ovh.plrapps.mapcompose.ui.gestures.model.HitType
import ovh.plrapps.mapcompose.ui.paths.PathData
import ovh.plrapps.mapcompose.ui.paths.PathDataBuilder
import ovh.plrapps.mapcompose.ui.paths.model.Cap
import ovh.plrapps.mapcompose.ui.paths.model.PatternItem
import ovh.plrapps.mapcompose.ui.state.MapState
/**
* Adds a path, optionally setting some properties.
*
* @param id The unique identifier of the path
* @param pathData Obtained from [PathDataBuilder.build]
* @param width The width of the path, in [Dp]. Defaults to 4.dp
* @param color The color of the path. Defaults to Color(0xFF448AFF)
* @param fillColor If set - the path will be filled and the area drawn in this color.
* @param offset The number of points to skip from the beginning of the path. Defaults to 0.
* @param count The number of points to draw after [offset]. Defaults to the number of points added
* to built [pathData].
* @param cap The cap style at the start and end of the path. Defaults to [Cap.Round].
* @param simplify By default, the path is simplified depending on the scale to improve performance.
* Higher values increase the simplification effect, while a value of 0f effectively disables path
* simplification. Sensible values a typically in the range [0.5f..2f]. Default value is 1f.
* @param clickable Controls whether the path is clickable. Default is false. If a click listener
* is registered using [onPathClick], that listener will be invoked for that marker if [clickable]
* is true.
* @param zIndex A path with larger zIndex will be drawn on top of paths with smaller zIndex.
* When paths have the same zIndex, the more recently added path is drawn on top of the others.
* @param pattern The dash pattern. By default, no dash effect is applied.
*/
fun MapState.addPath(
id: String,
pathData: PathData,
width: Dp? = null,
color: Color? = null,
fillColor: Color? = null,
offset: Int? = null,
count: Int? = null,
cap: Cap = Cap.Round,
simplify: Float? = null,
clickable: Boolean = false,
zIndex: Float = 0f,
pattern: List? = null
) {
pathState.addPath(id, pathData, width, color, fillColor, offset, count, cap, simplify, clickable, zIndex, pattern)
}
/**
* Adds a path, optionally setting some properties.
*
* @param id The unique identifier of the path
* @param width The width of the path, in [Dp]. Defaults to 4.dp
* @param color The color of the path. Defaults to Color(0xFF448AFF)
* @param offset The number of points to skip from the beginning of the path. Defaults to 0.
* @param count The number of points to draw after [offset]. Defaults to the number of points
* provided inside the [builder] block.
* @param cap The cap style at the start and end of the path. Defaults to [Cap.Round].
* @param simplify By default, the path is simplified depending on the scale to improve performance.
* Higher values increase the simplification effect, while a value of 0f effectively disables path
* simplification. Sensible values a typically in the range [0.5f..2f]. Default value is 1f.
* @param clickable Controls whether the path is clickable. Default is false. If a click listener
* is registered using [onPathClick], that listener will be invoked for that marker if [clickable]
* is true.
* @param zIndex A path with larger zIndex will be drawn on top of paths with smaller zIndex.
* When paths have the same zIndex, the more recently added path is drawn on top of the others.
* @param pattern The dash pattern. By default, no dash effect is applied.
* @param builder The builder block from with to add individual points or list of points.
*
* @return The [PathData] which can be used for adding other paths.
*/
fun MapState.addPath(
id: String,
width: Dp? = null,
color: Color? = null,
fillColor: Color? = null,
offset: Int? = null,
count: Int? = null,
cap: Cap = Cap.Round,
simplify: Float? = null,
clickable: Boolean = false,
zIndex: Float = 0f,
pattern: List? = null,
builder: (PathDataBuilder).() -> Unit
): PathData? {
val pathData = makePathDataBuilder().apply { builder() }.build() ?: return null
pathState.addPath(id, pathData, width, color, fillColor, offset, count, cap, simplify, clickable, zIndex, pattern)
return pathData
}
/**
* Updates some properties of a previously added path (using [addPath]).
*
* @param id The unique identifier of the path
* @param pathData The points of the path. The [PathDataBuilder] which was originally used to create
* the path can be used to build new [PathData] instances with additional points.
* @param width The width of the path, in [Dp]
* @param color The color of the path
* @param offset The number of points to skip from the beginning of the path
* @param count The number of points to draw after [offset]
* @param cap The cap style at the start and end of the path
* @param simplify By default, the path is simplified depending on the scale to improve performance.
* Higher values increase the simplification effect, while a value of 0f effectively disables path
* simplification. Sensible values a typically in the range [0.5f..2f]. Default value is 1f.
* @param zIndex A path with larger zIndex will be drawn on top of paths with smaller zIndex.
* When paths have the same zIndex, the more recently added path is drawn on top of the others.
* @param clickable Controls whether the path is clickable.
* @param pattern The dash pattern. By default, no dash effect is applied.
*/
fun MapState.updatePath(
id: String,
pathData: PathData? = null,
visible: Boolean? = null,
width: Dp? = null,
color: Color? = null,
fillColor: Color? = null,
offset: Int? = null,
count: Int? = null,
cap: Cap? = null,
simplify: Float? = null,
clickable: Boolean? = null,
zIndex: Float? = null,
pattern: List? = null
) {
pathState.updatePath(id, pathData, visible, width, color, fillColor, offset, count, cap, simplify, clickable, zIndex, pattern)
}
/**
* Removes a path.
*
* @param id The id of the path
*/
fun MapState.removePath(id: String) {
pathState.removePath(id)
}
/**
* Removes all paths.
*/
fun MapState.removeAllPaths() {
pathState.removeAllPaths()
}
/**
* Remove paths given a predicate which operates on path id.
*/
fun MapState.removePaths(predicate: (id: String) -> Boolean) {
pathState.removePaths(predicate)
}
/**
* Check whether a path was already added or not.
*
* @param id The id of the path
*/
fun MapState.hasPath(id: String): Boolean {
return pathState.hasPath(id)
}
/**
* Get a new instance of [PathDataBuilder].
* Adding a path is done using [addPath], which requires a [PathData] instance. A [PathData]
* instance can only be built using a [PathDataBuilder].
*/
fun MapState.makePathDataBuilder(): PathDataBuilder {
return PathDataBuilder(zoomPanRotateState.fullWidth, zoomPanRotateState.fullHeight)
}
/**
* Register a callback which will be invoked when a path is tapped.
* Beware that this click listener will only be invoked if at least one path is clickable, and when
* the click gesture isn't already consumed by some other composable (like a button), or a marker.
* When several paths hover each other, the [cb] is invoked for the path with the highest z-index
* and which is the last drawn.
*/
fun MapState.onPathClick(cb: (id: String, x: Double, y: Double) -> Unit) {
pathState.pathClickCb = cb
}
/**
* Register a callback which will be invoked when a path is long-clicked.
* Beware that the provided callback will only be invoked if at least one path is clickable, and when
* the gesture isn't already consumed by some other composable (like a button), or a marker.
* When several paths hover each other, the [cb] is invoked for the path with the highest z-index.
*/
fun MapState.onPathLongPress(cb: (id: String, x: Double, y: Double) -> Unit) {
pathState.pathLongPressCb = cb
}
/**
* Register a callback which will be invoked when one or more paths are tapped.
* /!\ This api takes precedence over the [onPathClick] and [onPathLongPress] apis. For example,
* when [onPathHitTraversal] is set, the callback registered with [onPathClick] isn't invoked.
* Beware that this click listener will only be invoked if at least one path is clickable, and when
* the click gesture isn't already consumed by some other composable (like a button), or a marker.
* When several paths hover each other, the [cb] is invoked for all paths, regardless of their
* z-index.
*
* To unregister the callback, set it to null.
*/
fun MapState.onPathHitTraversal(cb: ((ids: List, x: Double, y: Double, hitType: HitType) -> Unit)?) {
pathState.pathHitTraversalCb = cb
}
/**
* When application code lost reference on a [PathData], this api can be useful to retrieve the
* [PathData] instance.
* A typical use case is to draw a new path on top or underneath the path with id [id].
*/
fun MapState.getPathData(id: String): PathData? {
return pathState.pathState[id]?.pathData
}
/**
* Loops on all paths and snapshots each path properties.
* Useful to loop and update paths depending on their properties.
*/
fun MapState.allPaths(block: MapState.(properties: PathProperties) -> Unit) {
pathState.pathState.values.forEach { drawablePathState ->
val properties = PathProperties(
id = drawablePathState.id,
visible = drawablePathState.visible,
width = drawablePathState.width,
color = drawablePathState.color,
offset = drawablePathState.offsetAndCount.x,
count = drawablePathState.offsetAndCount.y,
cap = drawablePathState.cap,
simplify = drawablePathState.simplify,
clickable = drawablePathState.isClickable,
zIndex = drawablePathState.zIndex
)
block(properties)
}
}
/**
* Checks if a circle centered on ([x], [y]) with a radius of [rangePx] at scale 1 intersects the
* path with id = [id].
*/
fun MapState.isPathWithinRange(id: String, rangePx: Int, x: Double, y: Double): Boolean {
return pathState.isPathWithinRange(id, rangePx, x, y)
}
/**
* Removes the dash effect of a path.
*/
fun MapState.removePathPattern(id: String) {
pathState.pathState[id]?.apply {
pattern = null
}
}
data class PathProperties(
val id: String, val visible: Boolean, val width: Dp, val color: Color, val offset: Int,
val count: Int, val cap: Cap, val simplify: Float, val clickable: Boolean,
val zIndex: Float
)
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/api/RenderApi.kt
================================================
@file:Suppress("unused")
package ovh.plrapps.mapcompose.api
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import kotlinx.coroutines.launch
import ovh.plrapps.mapcompose.core.ColorFilterProvider
import ovh.plrapps.mapcompose.ui.state.MapState
/**
* Reloads all tiles.
*/
fun MapState.reloadTiles() {
scope.launch {
tileCanvasState.forgetTiles()
renderVisibleTilesThrottled()
}
}
/**
* Controls the fade-in effect of tiles. Provided speed should be in the range [0.01f, 1.0f].
* Values below 0.04f aren't recommended (can cause blinks), the default is 0.07f.
* A [speed] of 1f effectively disables the fade-in effect.
*/
fun MapState.setFadeInSpeed(speed: Float) {
scope.launch {
tileCanvasState.alphaTick = speed
}
}
/**
* Disables the fade-in effect of tiles.
*/
fun MapState.disableFadeIn() {
scope.launch {
tileCanvasState.alphaTick = 1f
}
}
/**
* Applies a [ColorFilter] for each tile. A different [ColorFilter] can be applied depending on the
* coordinate of tiles.
* This change triggers a re-composition (effects are immediately visible).
*/
fun MapState.setColorFilterProvider(provider: ColorFilterProvider) {
tileCanvasState.colorFilterProvider = provider
}
/**
* Sets the background color visible before tiles are loaded or when the canvas outside of the
* map area is in view.
*/
fun MapState.setMapBackground(color: Color) {
mapBackground = color
}
/**
* Controls whether Bitmap filtering is enabled when drawing tiles. This is enabled by default.
* Disabling it is useful to achieve nearest-neighbor scaling, for cases when the art style of the
* displayed image benefits from it.
* @see [android.graphics.Paint.setFilterBitmap]
*/
fun MapState.setBitmapFilteringEnabled(enabled: Boolean) {
setBitmapFilteringEnabled { enabled }
}
/**
* A version of [setBitmapFilteringEnabled] which allows for dynamic control of bitmap filtering
* depending on the current [MapState].
*/
fun MapState.setBitmapFilteringEnabled(predicate: (state: MapState) -> Boolean) {
isFilteringBitmap = { predicate(this) }
}
/**
* Virtually increase the size of the screen by a padding in pixel amount.
* With the appropriate value, this can be used to produce a seamless tile loading effect.
*
* @param padding in pixels
*/
fun MapState.setPreloadingPadding(padding: Int) {
scope.launch {
preloadingPadding = padding.coerceAtLeast(0)
renderVisibleTilesThrottled()
}
}
/**
* The magnifying factor alters the level at which tiles are picked for a given scale. By default,
* the level immediately higher (in index) is picked, to avoid sub-sampling. This corresponds to a
* magnifying factor of 0. The value 1 will result in picking the current level at a given scale,
* which will be at a relative scale between 1.0 and 2.0
*/
fun MapState.setMagnifyingFactor(factor: Int) {
scope.launch {
visibleTilesResolver.magnifyingFactor = factor
renderVisibleTilesThrottled()
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/api/UtilsApi.kt
================================================
@file:Suppress("unused")
package ovh.plrapps.mapcompose.api
import ovh.plrapps.mapcompose.ui.state.MapState
import ovh.plrapps.mapcompose.utils.*
/**
* Given a [point] with known normalized coordinates, rotate it by [angleDegree] around the current
* centroid.
*/
suspend fun MapState.rotatePoint(point: Point, angleDegree: AngleDegree): Point {
return with(zoomPanRotateState) {
awaitLayout()
val xAxisScale = fullHeight / fullWidth.toDouble()
val scaledCenterX = centroidX / xAxisScale
val xR = rotateCenteredX(
point.x / xAxisScale, point.y, scaledCenterX, centroidY, angleDegree.toRad()
) * xAxisScale
val yR = rotateCenteredY(
point.x / xAxisScale, point.y, scaledCenterX, centroidY, angleDegree.toRad()
)
Point(xR, yR)
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/core/ColorFilterProvider.kt
================================================
package ovh.plrapps.mapcompose.core
import androidx.compose.ui.graphics.ColorFilter
/**
* Provides a [ColorFilter] for a tile coordinate.
*/
fun interface ColorFilterProvider {
/* Must not be a blocking call - should return immediately */
fun getColorFilter(row: Int, col: Int, zoomLvl: Int): ColorFilter?
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/core/Debounce.kt
================================================
package ovh.plrapps.mapcompose.core
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
/**
* So long as the returned [SendChannel] receives [T] elements, the provided [block] function isn't
* executed until a time-span of [timeoutMillis] elapses.
* When [block] is executed, it's provided with the last [T] value sent to the channel.
*/
@OptIn(FlowPreview::class)
fun CoroutineScope.debounce(
timeoutMillis: Long,
block: suspend (T) -> Unit
): SendChannel {
val channel = Channel(capacity = Channel.CONFLATED)
val flow = channel.receiveAsFlow().debounce(timeoutMillis)
launch {
flow.collect {
block(it)
}
}
return channel
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/core/GestureConfiguration.kt
================================================
package ovh.plrapps.mapcompose.core
import android.view.ViewConfiguration
/**
* Configuration of various gestures.
* Scroll fling friction is controlled by [ViewConfiguration.getScrollFriction].
*/
class GestureConfiguration {
/**
* The friction multiplier of the zoom fling, indicating how quickly the animation should stop.
* This should be greater than 0, with a default value of 1.5f. Minimum allowed value is 0.5f.
*/
var flingZoomFriction: Float = 1.5f
set(value) {
field = value.coerceAtLeast(0.5f)
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/core/Layer.kt
================================================
package ovh.plrapps.mapcompose.core
import java.util.*
internal data class Layer(
val id: String,
val tileStreamProvider: TileStreamProvider,
val alpha: Float = 1f
)
sealed interface LayerPlacement
data object AboveAll : LayerPlacement
data object BelowAll : LayerPlacement
data class AboveLayer(val layerId: String) : LayerPlacement
data class BelowLayer(val layerId: String) : LayerPlacement
internal fun makeLayerId(): String = UUID.randomUUID().toString()
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/core/Throttle.kt
================================================
package ovh.plrapps.mapcompose.core
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
/**
* Limit the rate at which a [block] is called.
* The [block] execution is triggered upon reception of [Unit] from the returned [SendChannel].
*
* @param wait The time in ms between each [block] call.
*
* @author P.Laurence
*/
fun CoroutineScope.throttle(wait: Long, block: suspend () -> Unit): SendChannel {
val channel = Channel(capacity = Channel.CONFLATED)
val flow = channel.receiveAsFlow()
launch {
flow.collect {
block()
delay(wait)
}
}
return channel
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/core/Tile.kt
================================================
package ovh.plrapps.mapcompose.core
import android.graphics.Bitmap
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlin.time.TimeSource
/**
* A [Tile] is defined by its coordinates in the "pyramid". A [Tile] is sub-sampled when the
* scale becomes lower than the scale of the lowest level. To reflect that, there is [subSample]
* property which is a positive integer (can be 0). When [subSample] equals 0, the [bitmap] of the
* tile is full scale. When [subSample] equals 1, the [bitmap] is sub-sampled and its size is half
* the original bitmap (the one at the lowest level), and so on.
*/
internal data class Tile(
val zoom: Int,
val row: Int,
val col: Int,
val subSample: Int,
val layerIds: List,
val opacities: List
) {
@Volatile
var bitmap: Bitmap? = null // write on main-thread only
var alpha: Float by mutableFloatStateOf(0f)
@Volatile
var overlaps: Tile? = null
@Volatile
var markedForSweep = false // write on main-thread only
var phases: IntRange? by mutableStateOf(null)
@Volatile
var timeMark: TimeSource.Monotonic.ValueTimeMark? = null
}
internal data class TileSpec(val zoom: Int, val row: Int, val col: Int, val subSample: Int = 0)
internal fun Tile.spaceKey(): SpaceKey {
return "row=$row,col=$col,zoom=$zoom"
}
internal typealias SpaceKey = String
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/core/TileCollector.kt
================================================
package ovh.plrapps.mapcompose.core
import android.graphics.Bitmap
import android.graphics.Bitmap.Config
import android.graphics.Bitmap.createBitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.os.Build
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Runnable
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import java.io.InputStream
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.SynchronousQueue
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit
import kotlin.math.pow
/**
* The engine of MapCompose. The view-model uses two channels to communicate with the [TileCollector]:
* * one to send [TileSpec]s (a [SendChannel])
* * one to receive [TileSpec]s (a [ReceiveChannel])
*
* The [TileCollector] encapsulates all the complexity that transforms a [TileSpec] into a [Tile].
* ```
* _____________________________________________________________________
* | TileCollector ____________ |
* tiles | | ________ | |
* ---------------- [*********] <----------------------------------------------------- | | worker | | |
* | | | -------- | |
* ↓ | | ________ | |
* _____________________ | tileSpecs | | worker | | |
* | TileCanvasViewModel | | _____________________ <---- [**********] <---- | -------- | |
* --------------------- ----> [*********] ----> | tileCollectorKernel | | ________ | |
* tileSpecs | --------------------- ----> [**********] ----> | | worker | | |
* | tileSpecs | -------- | |
* | |____________| |
* | worker pool |
* | |
* ---------------------------------------------------------------------
* ```
* This architecture is an example of Communicating Sequential Processes (CSP).
*
* @author p-lr on 22/06/19
*/
internal class TileCollector(
private val workerCount: Int,
private val optimizeForLowEndDevices: Boolean,
private val tileSize: Int
) {
@Volatile
var isIdle: Boolean = true
/**
* Sets up the tile collector machinery. The architecture is inspired from
* [Kotlin Conf 2018](https://www.youtube.com/watch?v=a3agLJQ6vt8).
* It support back-pressure, and avoids deadlock in CSP taking into account recommendations of
* this [article](https://medium.com/@elizarov/deadlocks-in-non-hierarchical-csp-e5910d137cc),
* which is from the same author.
*
* @param [tileSpecs] channel of [TileSpec], which capacity should be [Channel.RENDEZVOUS].
* @param [tilesOutput] channel of [Tile], which should be set as [Channel.RENDEZVOUS].
*/
suspend fun collectTiles(
tileSpecs: ReceiveChannel,
tilesOutput: SendChannel,
layers: List,
) = coroutineScope {
val tilesToDownload = Channel(capacity = Channel.RENDEZVOUS)
val tilesDownloadedFromWorker = Channel(capacity = 1)
repeat(workerCount) {
worker(
tilesToDownload = tilesToDownload,
tilesDownloaded = tilesDownloadedFromWorker,
tilesOutput = tilesOutput,
layers = layers
)
}
tileCollectorKernel(tileSpecs, tilesToDownload, tilesDownloadedFromWorker)
}
private fun CoroutineScope.worker(
tilesToDownload: ReceiveChannel,
tilesDownloaded: SendChannel,
tilesOutput: SendChannel,
layers: List,
) = launch(dispatcher) {
val layerIds = layers.map { it.id }
val canUseHardwareBitmaps = canUseHardwareBitmaps()
/**
* This config is for the software canvas, which is used in two situations:
* 1. There's more than one layer. We use a software canvas before copying the result either
* on a hardware bitmap (if we can use hardware bitmaps), or to another software bitmap.
* 2. There's exactly one layer. Then, [Config.RGB_565] is suitable when we're optimizing
* for low-end devices. This config won't be used if we can use hardware bitmaps.
*/
val config = if (layers.size == 1 && optimizeForLowEndDevices) {
Config.RGB_565
} else {
Config.ARGB_8888
}
val bitmapLoadingOptionsForLayer = layerIds.associateWith {
BitmapFactory.Options().apply {
inPreferredConfig = config
}
}
/* If we can't use hardware bitmaps or we have two or more layers, we need to work with
* a software canvas */
val shouldUseSoftwareCanvas = layers.size > 1 || !canUseHardwareBitmaps
val bitmapForLayer = if (shouldUseSoftwareCanvas) {
layerIds.associateWith {
createBitmap(tileSize, tileSize, config)
}
} else emptyMap()
val canvas = Canvas()
val paint = Paint(Paint.FILTER_BITMAP_FLAG)
fun getBitmap(
subSamplingRatio: Int,
layer: Layer,
inputStream: InputStream,
): BitmapForLayer {
val bitmapLoadingOptions =
bitmapLoadingOptionsForLayer[layer.id] ?: return BitmapForLayer(null, layer)
bitmapLoadingOptions.inSampleSize = subSamplingRatio
if (shouldUseSoftwareCanvas) {
bitmapLoadingOptions.inMutable = true
bitmapLoadingOptions.inBitmap = bitmapForLayer[layer.id]
} else {
bitmapLoadingOptions.inPreferredConfig = Config.HARDWARE
}
return inputStream.use {
val bitmap = runCatching {
BitmapFactory.decodeStream(inputStream, null, bitmapLoadingOptions)
}.getOrNull()
BitmapForLayer(bitmap, layer)
}
}
for (spec in tilesToDownload) {
if (layers.isEmpty()) {
tilesDownloaded.send(spec)
continue
}
val subSamplingRatio = 2.0.pow(spec.subSample).toInt()
val bitmapForLayers = layers.mapIndexed { index, layer ->
async {
val i = layer.tileStreamProvider.getTileStream(spec.row, spec.col, spec.zoom)
if (i != null) {
getBitmap(
subSamplingRatio = subSamplingRatio,
layer = layer,
inputStream = i
)
} else BitmapForLayer(null, layer)
}
}.awaitAll()
val primaryLayerBitmap = bitmapForLayers.firstOrNull()?.bitmap ?: run {
tilesDownloaded.send(spec)
/* When the decoding failed or if there's nothing to decode, then send back the Tile
* just as in normal processing, so that the actor which submits tiles specs to the
* collector knows that this tile has been processed and does not immediately
* re-sends the same spec. */
tilesOutput.send(
Tile(
spec.zoom,
spec.row,
spec.col,
spec.subSample,
layerIds,
layers.map { it.alpha }
)
)
null
} ?: continue // If the decoding of the first layer failed, skip the rest
if (layers.size > 1) {
canvas.setBitmap(primaryLayerBitmap)
for (result in bitmapForLayers.drop(1)) {
paint.alpha = (255f * result.layer.alpha).toInt()
if (result.bitmap == null) continue
canvas.drawBitmap(result.bitmap, 0f, 0f, paint)
}
}
val resultBitmap = if (canUseHardwareBitmaps) {
if (layers.size > 1) {
primaryLayerBitmap.copy(Config.HARDWARE, false)
} else primaryLayerBitmap
} else {
primaryLayerBitmap.copy(config, false)
}
val tile = Tile(
spec.zoom,
spec.row,
spec.col,
spec.subSample,
layerIds,
layers.map { it.alpha }
).apply {
this.bitmap = resultBitmap
}
tilesOutput.send(tile)
tilesDownloaded.send(spec)
}
}
private fun CoroutineScope.tileCollectorKernel(
tileSpecs: ReceiveChannel,
tilesToDownload: SendChannel,
tilesDownloadedFromWorker: ReceiveChannel,
) = launch(Dispatchers.Default) {
val specsBeingProcessed = mutableListOf()
while (true) {
select {
tilesDownloadedFromWorker.onReceive {
specsBeingProcessed.remove(it)
isIdle = specsBeingProcessed.isEmpty()
}
tileSpecs.onReceive {
if (it !in specsBeingProcessed) {
/* Add it to the list of specs being processed */
specsBeingProcessed.add(it)
isIdle = false
/* Now download the tile */
tilesToDownload.send(it)
}
}
}
}
}
/**
* Attempts to stop all actively executing tasks, halts the processing of waiting tasks.
*/
fun shutdownNow() {
executor.shutdownNow()
}
/**
* On Android O+, ART has a more efficient GC and HARDWARE Bitmaps are supported, making
* Bitmap re-use much less important.
* However:
* - a framework issue pre Q requires to wait until GL context is initialized. Otherwise,
* allocating a hardware Bitmap can cause a native crash.
* - Allocating a hardware Bitmap involves the creation of a file descriptor. Android O, as well
* as some P devices, have a maximum of 1024 file descriptors. Android Q+ devices have a much
* higher limit of fd.
*
* To avoid all those issues entirely, we enable HARDWARE Bitmaps on Android Q and above.
* We don't monitor the file descriptor count because in practice, MapCompose creates a few
* hundreds of them and they seem to be efficiently recycled.
*/
private fun canUseHardwareBitmaps(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
}
/**
* When using a [LinkedBlockingQueue], the core pool size mustn't be 0, or the active thread
* count won't be greater than 1. Previous versions used a [SynchronousQueue], which could have
* a core pool size of 0 and a growing count of active threads. However, a [Runnable] could be
* rejected when no thread were available. Starting from kotlinx.coroutines 1.4.0, this cause
* the associated coroutine to be cancelled. By using a [LinkedBlockingQueue], we avoid rejections.
*/
private val executor = ThreadPoolExecutor(
workerCount, workerCount,
60L, TimeUnit.SECONDS, LinkedBlockingQueue()
).apply {
allowCoreThreadTimeOut(true)
}
private val dispatcher = executor.asCoroutineDispatcher()
}
private data class BitmapForLayer(val bitmap: Bitmap?, val layer: Layer)
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/core/TileStreamProvider.kt
================================================
package ovh.plrapps.mapcompose.core
import java.io.InputStream
/**
* Defines how tiles should be fetched. It must be supplied as part of the configuration of
* MapCompose.
*
* The [getTileStream] method implementation may suspend, but it isn't required (e.g, it isn't
* required to switch context using withContext(Dispatcher.IO) { .. }) as MapCompose does that
* already. The [getTileStream] method is declared using the suspend modifier, as it is sometimes
* useful to provide an implementation which suspends.
*
* MapCompose leverages bitmap pooling to reduce the pressure on the garbage collector. However,
* there's no tile caching by default - this is an implementation detail of the supplied
* [TileStreamProvider].
*
* If [getTileStream] returns null, the tile won't be rendered.
* The library does not handle exceptions thrown from [getTileStream]. Such errors are treated as
* unrecoverable failures.
*/
fun interface TileStreamProvider {
suspend fun getTileStream(row: Int, col: Int, zoomLvl: Int): InputStream?
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/core/Viewport.kt
================================================
package ovh.plrapps.mapcompose.core
import ovh.plrapps.mapcompose.utils.AngleRad
/**
* Denotes an area on the screen. Values are in pixels.
*/
data class Viewport(
var left: Int = 0, var top: Int = 0, var right: Int = 0, var bottom: Int = 0,
var angleRad: AngleRad = 0f
)
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/core/VisibleTilesResolver.kt
================================================
package ovh.plrapps.mapcompose.core
import ovh.plrapps.mapcompose.utils.rotateX
import ovh.plrapps.mapcompose.utils.rotateY
import kotlin.math.*
import kotlin.time.TimeSource
/**
* Resolves the visible tiles.
* This class isn't thread-safe, and public methods should be invoked from the same thread to ensure
* consistency.
*
* @param levelCount Number of levels
* @param fullWidth Width of the map at scale 1.0
* @param fullHeight Height of the map at scale 1.0
* @param magnifyingFactor Alters the level at which tiles are picked for a given scale. By default,
* the level immediately higher (in index) is picked, to avoid sub-sampling. This corresponds to a
* [magnifyingFactor] of 0. The value 1 will result in picking the current level at a given scale,
* which will be at a relative scale between 1.0 and 2.0
* @param scaleProvider Since the component which invokes [getVisibleTiles] isn't likely to be the
* component which owns the scale state, we provide it here as a loosely coupled reference.
*
* @author p-lr on 25/05/2019
*/
internal class VisibleTilesResolver(
private val levelCount: Int,
private val fullWidth: Int,
private val fullHeight: Int,
private val tileSize: Int = 256,
var magnifyingFactor: Int = 0,
private val infiniteScrollX: Boolean = false,
private val scaleProvider: ScaleProvider,
) {
/**
* Last level is at scale 1.0, others are at scale 1.0 / power_of_2
*/
private val scaleForLevel: Map = (0 until levelCount).associateWith {
(1.0 / 2.0.pow((levelCount - it - 1)))
}
/**
* Get the scale for a given [level] (also called zoom).
* @return the scale or null if no such level was configured.
*/
fun getScaleForLevel(level: Int): Double? {
return scaleForLevel[level]
}
fun getColCountForLevel(level: Int): Int? {
val scale = scaleForLevel[level] ?: return null
return max(0.0, ceil(fullWidth * scale / tileSize) - 1).toInt() + 1
}
/**
* Returns the level, an entire value belonging to [0 ; [levelCount] - 1]
*/
internal fun getLevel(scale: Double, magnifyingFactor: Int = 0): Int {
/* This value can be negative */
val partialLevel = levelCount - 1 - magnifyingFactor +
ln(scale) / ln(2.0)
/* The level can't be greater than levelCount - 1.0 */
val capedLevel = min(partialLevel, levelCount - 1.0)
/* The level can't be lower than 0 */
return ceil(max(capedLevel, 0.0)).toInt()
}
/**
* Get the [VisibleTiles], given the visible area in pixels.
*
* @param viewport The [Viewport] which represents the visible area. Its values depend on the
* scale.
*/
fun getVisibleTiles(viewport: Viewport): VisibleTiles {
val scale = scaleProvider.getScale()
val level = getLevel(scale, magnifyingFactor)
val scaleAtLevel = scaleForLevel[level] ?: throw AssertionError()
val relativeScale = scale / scaleAtLevel
/* At the current level, row and col index have maximum values */
val maxCol = max(0.0, ceil(fullWidth * scaleAtLevel / tileSize) - 1).toInt()
val maxRow = max(0.0, ceil(fullHeight * scaleAtLevel / tileSize) - 1).toInt()
fun Int.lowerThan(limit: Int): Int {
return if (this <= limit) this else limit
}
val scaledTileSize = tileSize.toDouble() * relativeScale
fun makeVisibleTiles(left: Int, top: Int, right: Int, bottom: Int): VisibleTiles {
val colLeft = floor(left / scaledTileSize).toInt().lowerThan(maxCol).coerceAtLeast(0)
val rowTop = floor(top / scaledTileSize).toInt().lowerThan(maxRow).coerceAtLeast(0)
val colRight = (ceil(right / scaledTileSize).toInt() - 1).lowerThan(maxCol)
val rowBottom = (ceil(bottom / scaledTileSize).toInt() - 1).lowerThan(maxRow)
val tileMatrix = (rowTop..rowBottom).associateWith {
colLeft..colRight
}
val visibleWindow = if (infiniteScrollX) {
val colCnt = maxCol + 1
val overflowLeft = if (left < 0) {
val leftOverflow = floor(left / scaledTileSize)
val phaseForColLeft = buildMap {
for (c in leftOverflow.toInt()..<0) {
val remainder = c + (abs(c) / colCnt) * colCnt
val col = if (remainder < 0) {
colCnt + remainder
} else 0
val phase = floor(c.toDouble() / colCnt).toInt()
if (phase < 0 && phase < (get(col) ?: 0)) {
put(col, phase)
}
}
}
val c = (abs(leftOverflow) - 1).toInt()
val colLeftL = (maxCol - c).coerceAtLeast(0)
val tileMatrixL = (rowTop..rowBottom).associateWith {
colLeftL..maxCol
}
Overflow(tileMatrixL, phaseForColLeft)
} else null
val rightOverflow = ceil(right / scaledTileSize) - 1
val overflowRight = if (rightOverflow > maxCol) {
val phaseForColRight = buildMap {
for (c in 0..<(rightOverflow - maxCol).toInt()) {
val col = c - (c / colCnt) * colCnt
val phase = floor(c.toDouble() / colCnt).toInt() + 1
if (phase > 0 && phase > (get(col) ?: 0)) {
put(col, phase)
}
}
}
val c = ((rightOverflow - maxCol).toInt() - 1).coerceAtLeast(0)
val colRightR = c.coerceAtMost(maxCol)
val tileMatrixR = (rowTop..rowBottom).associateWith {
0..colRightR
}
Overflow(tileMatrixR, phaseForColRight)
} else null
VisibleWindow.InfiniteScrollX(tileMatrix, overflowLeft, overflowRight, TimeSource.Monotonic.markNow())
} else {
VisibleWindow.BoundsConstrained(tileMatrix)
}
return VisibleTiles(level, visibleWindow, getSubSample(scale))
}
return if (viewport.angleRad == 0f) {
makeVisibleTiles(viewport.left, viewport.top, viewport.right, viewport.bottom)
} else {
val xTopLeft = viewport.left
val yTopLeft = viewport.top
val xTopRight = viewport.right
val yTopRight = viewport.top
val xBotLeft = viewport.left
val yBotLeft = viewport.bottom
val xBotRight = viewport.right
val yBotRight = viewport.bottom
val xCenter = (viewport.right + viewport.left).toDouble() / 2
val yCenter = (viewport.bottom + viewport.top).toDouble() / 2
val xTopLeftRot =
rotateX(xTopLeft - xCenter, yTopLeft - yCenter, viewport.angleRad) + xCenter
val yTopLeftRot =
rotateY(xTopLeft - xCenter, yTopLeft - yCenter, viewport.angleRad) + yCenter
var xLeftMost = xTopLeftRot
var yTopMost = yTopLeftRot
var xRightMost = xTopLeftRot
var yBotMost = yTopLeftRot
val xTopRightRot =
rotateX(xTopRight - xCenter, yTopRight - yCenter, viewport.angleRad) + xCenter
val yTopRightRot =
rotateY(xTopRight - xCenter, yTopRight - yCenter, viewport.angleRad) + yCenter
xLeftMost = xLeftMost.coerceAtMost(xTopRightRot)
yTopMost = yTopMost.coerceAtMost(yTopRightRot)
xRightMost = xRightMost.coerceAtLeast(xTopRightRot)
yBotMost = yBotMost.coerceAtLeast(yTopRightRot)
val xBotLeftRot =
rotateX(xBotLeft - xCenter, yBotLeft - yCenter, viewport.angleRad) + xCenter
val yBotLeftRot =
rotateY(xBotLeft - xCenter, yBotLeft - yCenter, viewport.angleRad) + yCenter
xLeftMost = xLeftMost.coerceAtMost(xBotLeftRot)
yTopMost = yTopMost.coerceAtMost(yBotLeftRot)
xRightMost = xRightMost.coerceAtLeast(xBotLeftRot)
yBotMost = yBotMost.coerceAtLeast(yBotLeftRot)
val xBotRightRot =
rotateX(xBotRight - xCenter, yBotRight - yCenter, viewport.angleRad) + xCenter
val yBotRightRot =
rotateY(xBotRight - xCenter, yBotRight - yCenter, viewport.angleRad) + yCenter
xLeftMost = xLeftMost.coerceAtMost(xBotRightRot)
yTopMost = yTopMost.coerceAtMost(yBotRightRot)
xRightMost = xRightMost.coerceAtLeast(xBotRightRot)
yBotMost = yBotMost.coerceAtLeast(yBotRightRot)
makeVisibleTiles(
xLeftMost.toInt(),
yTopMost.toInt(),
xRightMost.toInt(),
yBotMost.toInt()
)
}
}
// internal for test purposes
internal fun getSubSample(scale: Double): Int {
return if (scale < (scaleForLevel[0] ?: Double.MIN_VALUE)) {
ceil(ln((scaleForLevel[0] ?: error("")).toDouble() / scale) / ln(2.0)).toInt()
} else {
0
}
}
fun interface ScaleProvider {
fun getScale(): Double
}
}
/**
* Properties container for the computed visible tiles.
* @param level 0-based level index
* @param visibleWindow contains information about which tiles are currently visible
* @param subSample the current sub-sample factor. If the current scale of the [VisibleTilesResolver]
* is lower than the scale of the minimum level, [subSample] is greater than 0. Otherwise, [subSample]
* equals 0.
*/
internal data class VisibleTiles(
val level: Int,
val visibleWindow: VisibleWindow,
val subSample: Int = 0
)
internal typealias Row = Int
internal typealias Col = Int
internal typealias ColRange = IntRange
/* Contains all (row, col) indexes, grouped by rows*/
internal typealias TileMatrix = Map
internal sealed interface VisibleWindow {
data class BoundsConstrained(val tileMatrix: TileMatrix): VisibleWindow
data class InfiniteScrollX(
val tileMatrix: TileMatrix,
val leftOverflow: Overflow?,
val rightOverflow: Overflow?,
val timeMark: TimeSource.Monotonic.ValueTimeMark
): VisibleWindow
}
/**
* Contains information about which tiles should be repeated on one side and how.
* For example, if `phase[3]` returns -2, it means the tile of column index 3 should be repeated 2
* times on the left. If `phase[0]` returns 1, it means the tile of column index 0 should be drawn a
* single time on the right.
* A phase should always be different than 0.
*/
internal data class Overflow(val tileMatrix: TileMatrix, val phase: Map)
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/MapUI.kt
================================================
package ovh.plrapps.mapcompose.ui
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.zIndex
import ovh.plrapps.mapcompose.ui.layout.ZoomPanRotate
import ovh.plrapps.mapcompose.ui.markers.MarkerComposer
import ovh.plrapps.mapcompose.ui.paths.PathComposer
import ovh.plrapps.mapcompose.ui.state.MapState
import ovh.plrapps.mapcompose.ui.view.TileCanvas
@Composable
fun MapUI(
modifier: Modifier = Modifier,
state: MapState,
content: @Composable () -> Unit = {}
) {
val zoomPRState = state.zoomPanRotateState
val markerState = state.markerRenderState
val pathState = state.pathState
key(state) {
ZoomPanRotate(
modifier = modifier
.clipToBounds()
.background(state.mapBackground),
gestureListener = zoomPRState,
layoutSizeChangeListener = zoomPRState,
) {
TileCanvas(
modifier = Modifier,
zoomPRState = zoomPRState,
visibleTilesResolver = state.visibleTilesResolver,
tileSize = state.tileSize,
alphaTick = state.tileCanvasState.alphaTick,
colorFilterProvider = state.tileCanvasState.colorFilterProvider,
tilesToRender = state.tileCanvasState.tilesToRender,
isFilteringBitmap = state.isFilteringBitmap,
)
MarkerComposer(
modifier = Modifier.zIndex(1f),
zoomPRState = zoomPRState,
markerRenderState = markerState,
mapState = state
)
PathComposer(
modifier = Modifier,
zoomPRState = zoomPRState,
pathState = pathState
)
content()
}
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/gestures/GestureDetector.kt
================================================
package ovh.plrapps.mapcompose.ui.gestures
import androidx.compose.foundation.gestures.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.VelocityTracker1D
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.pow
/**
* A modified version of [detectTransformGestures] from the framework, which adds fling and
* two-fingers tap support.
*/
internal suspend fun PointerInputScope.detectTransformGestures(
panZoomLock: Boolean = false,
onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit,
onTouchDown: () -> Unit,
onTwoFingersTap: (centroid: Offset) -> Unit,
onFling: (velocity: Velocity) -> Unit,
onFlingZoom: (centroid: Offset, velocity: Float) -> Unit
) {
val flingVelocityThreshold = 200.dp.toPx().pow(2)
val flingVelocityMaxRange = -8000f..8000f
val flingZoomThreshold = 1f
val flingZoomVelocityFactor = 400 // lower value for faster fling
val twoFingersReleaseTolerance = 150 // in ms
awaitEachGesture {
var rotation = 0f
var zoom = 1f
var pan = Offset.Zero
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
var lockedToPanZoom = false
awaitFirstDown(requireUnconsumed = false)
onTouchDown()
val panVelocityTracker = VelocityTracker()
val zoomVelocityTracker = VelocityTracker1D(isDataDifferential = false)
var canceled: Boolean
var centroidTwoFingers = Offset.Unspecified
var lastTwoFingersDown = 0L
var lastTime = 0L
do {
val event = awaitPointerEvent()
canceled = event.changes.fastAny { it.isConsumed }
if (!canceled) {
val zoomChange = event.calculateZoom()
val rotationChange = event.calculateRotation()
val panChange = event.calculatePan()
pan += panChange
zoom *= zoomChange
rotation += rotationChange
if (!pastTouchSlop) {
val centroidSize = event.calculateCentroidSize(useCurrent = false)
val zoomMotion = abs(1 - zoom) * centroidSize
val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f)
val panMotion = pan.getDistance()
if (zoomMotion > touchSlop ||
rotationMotion > touchSlop ||
panMotion > touchSlop
) {
pastTouchSlop = true
lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
}
}
if (pastTouchSlop) {
val uptime =
event.changes.maxByOrNull { it.uptimeMillis }?.uptimeMillis ?: 0L
lastTime = uptime
panVelocityTracker.addPosition(uptime, pan)
/* For the fling velocity, only take into account the centroid size when the
* two fingers are down */
if (event.changes.size == 2 && event.changes.fastAll { it.pressed }) {
val size = event.calculateCentroidSize(useCurrent = true)
zoomVelocityTracker.addDataPoint(uptime, size)
lastTwoFingersDown = uptime
}
val centroid = event.calculateCentroid(useCurrent = false)
val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
if (effectiveRotation != 0f ||
zoomChange != 1f ||
panChange != Offset.Zero
) {
onGesture(centroid, panChange, zoomChange, effectiveRotation)
}
event.changes.fastForEach {
if (it.positionChanged()) {
it.consume()
}
}
}
/* When releasing from two fingers tap, only one of the two pointers is pressed.
* Note that this only detects the release of the two fingers. */
if (event.changes.size == 2
&& event.changes.fastAny { it.pressed }
&& event.changes.fastAny { !it.pressed }
) {
centroidTwoFingers = event.calculateCentroidIgnorePressed()
event.changes.forEach { it.consume() }
}
}
} while (!canceled && event.changes.fastAny { it.pressed })
// If changes where consumed in another gesture, no need to go further.
if (canceled) {
return@awaitEachGesture
}
// If there where some zooming involved, there might be some zoom fling.
// Then, no need to go further since we'll next check for two-fingers tap and fling.
if (zoom != 1f && pastTouchSlop) {
val velocity = runCatching {
zoomVelocityTracker.calculateVelocity()
}.getOrDefault(0f)
if (abs(velocity) > flingZoomThreshold
&& centroidTwoFingers != Offset.Unspecified
// Tolerate a slight delay between the release of the first and second finger
&& (lastTime - lastTwoFingersDown) < twoFingersReleaseTolerance
) {
onFlingZoom(centroidTwoFingers, velocity / flingZoomVelocityFactor)
}
return@awaitEachGesture
}
// In addition to not zooming, if there where no pan or the fingers didn't move enough
// to trigger a zoom or pan, it might be a two fingers tap.
if (pan == Offset.Zero || !pastTouchSlop) {
if (centroidTwoFingers != Offset.Unspecified) {
onTwoFingersTap(centroidTwoFingers)
}
} else {
// No zoom with pan: it might be a fling
val velocity = runCatching {
panVelocityTracker.calculateVelocity()
}.getOrDefault(Velocity.Zero)
val velocitySquared = velocity.x.pow(2) + velocity.y.pow(2)
val velocityCapped = Velocity(
velocity.x.coerceIn(flingVelocityMaxRange),
velocity.y.coerceIn(flingVelocityMaxRange)
)
if (velocitySquared > flingVelocityThreshold) {
onFling(velocityCapped)
}
}
}
}
/**
* Returns the centroid when releasing two fingers. One of the changes isn't pressed while the other
* one is still pressed.
*/
private fun PointerEvent.calculateCentroidIgnorePressed(): Offset {
var centroid = Offset.Zero
var centroidWeight = 0
changes.fastForEach { change ->
val position = change.position
centroid += position
centroidWeight++
}
return if (centroidWeight == 0) {
Offset.Unspecified
} else {
centroid / centroidWeight.toFloat()
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/gestures/TapGestureDetector.kt
================================================
package ovh.plrapps.mapcompose.ui.gestures
import androidx.compose.foundation.gestures.GestureCancellationException
import androidx.compose.foundation.gestures.PressGestureScope
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlin.math.abs
private val NoPressGesture: suspend PressGestureScope.(Offset) -> Unit = { }
/**
* A modified version of [detectTapGestures] from the framework, with the following differences:
* - can take [shouldConsumeTap] callback which is invoked to check whether a tap should be consumed.
* - can take [shouldConsumeLongPress] callback which is invoked to check whether a long-press should
* be consumed.
* When [shouldConsumeTap] returns true, [onTap] isn't invoked and the gesture ends there without
* waiting for [ViewConfiguration.doubleTapMinTimeMillis].
* When a long-press gesture is detected, [shouldConsumeLongPress] is invoked, and [onLongPress] is
* invoked only when the long-press isn't consumed.
* - takes a [onDoubleTapZoom] callback for one finger zooming by double tapping but not releasing
* on the second tap, and then sliding the finger up to zoom out, or down to zoom in.
* Consequently, this gesture detector doesn't try to detect a long-press after the
* second tap, and a double-tap can no-longer timeout.
*/
internal suspend fun PointerInputScope.detectTapGestures(
onDoubleTap: ((Offset) -> Unit)? = null,
onDoubleTapZoom: (centroid: Offset, zoom: Float) -> Unit,
onDoubleTapZoomFling: (centroid: Offset, velocity: Float) -> Unit,
onLongPress: ((Offset) -> Unit)? = null,
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
onTap: ((Offset) -> Unit)? = null,
shouldConsumeTap: ((Offset) -> Boolean)? = null,
shouldConsumeLongPress: ((Offset) -> Boolean) ? = null
) = coroutineScope {
// special signal to indicate to the sending side that it shouldn't intercept and consume
// cancel/up events as we're only require down events
val pressScope = PressGestureScopeImpl(this@detectTapGestures)
val flingZoomThreshold = 1f
val flingZoomVelocityFactor = 400 // lower value for faster fling
awaitEachGesture {
val down = awaitFirstDown()
down.consume()
launch {
pressScope.reset()
}
if (onPress !== NoPressGesture) launch {
pressScope.onPress(down.position)
}
val longPressTimeout = onLongPress?.let {
viewConfiguration.longPressTimeoutMillis
} ?: (Long.MAX_VALUE / 2)
var upOrCancel: PointerInputChange? = null
try {
// wait for first tap up or long press
upOrCancel = withTimeout(longPressTimeout) {
waitForUpOrCancellation()
}
if (upOrCancel == null) {
launch {
pressScope.cancel() // tap-up was canceled
}
} else {
upOrCancel.consume()
launch {
pressScope.release()
}
}
} catch (_: PointerEventTimeoutCancellationException) {
val longPressConsumed = shouldConsumeLongPress?.invoke(down.position) ?: false
if (!longPressConsumed) {
onLongPress?.invoke(down.position)
}
consumeUntilUp()
pressScope.release()
}
if (upOrCancel != null) {
// tap was successful.
val tapConsumed = shouldConsumeTap?.invoke(upOrCancel.position) ?: false
if (tapConsumed) return@awaitEachGesture
if (onDoubleTap == null) {
onTap?.invoke(upOrCancel.position) // no need to check for double-tap.
} else {
// check for second tap
val secondDown = awaitSecondDown(upOrCancel)
if (secondDown == null) { // no valid second tap started
onTap?.invoke(upOrCancel.position)
} else {
// Second tap down detected
launch {
pressScope.reset()
}
if (onPress !== NoPressGesture) {
launch { pressScope.onPress(secondDown.position) }
}
// Now, either double-tap or zoom gesture. This is where we deviate
// from the framework : no timeout to detect long-press.
val secondUp = waitForUpOrCancellation()
if (secondUp != null) {
secondUp.consume()
launch {
pressScope.release()
}
onDoubleTap(secondUp.position)
} else {
val zoomVelocityTracker = VelocityTracker()
var pan = Offset.Zero
do {
val event = awaitPointerEvent()
val canceled = event.changes.fastAny { it.isConsumed }
if (!canceled) {
val panChange = event.calculatePan()
pan += panChange
val zoom = (size.height + panChange.y * density) / size.height
val uptime = event.changes.maxByOrNull { it.uptimeMillis }?.uptimeMillis ?: 0L
zoomVelocityTracker.addPosition(uptime, pan)
onDoubleTapZoom(secondDown.position, zoom)
event.changes.fastForEach {
if (it.positionChanged()) {
it.consume()
}
}
}
} while (!canceled && event.changes.fastAny { it.pressed })
launch {
pressScope.cancel()
}
/* Depending on the velocity, we might trigger a fling */
zoomVelocityTracker.calculateVelocity()
val velocity = runCatching {
zoomVelocityTracker.calculateVelocity()
}.getOrDefault(Velocity.Zero).y
if (abs(velocity) > flingZoomThreshold) {
onDoubleTapZoomFling(
secondDown.position,
velocity / flingZoomVelocityFactor
)
}
}
}
}
}
}
}
/**
* Consumes all pointer events until nothing is pressed and then returns. This method assumes
* that something is currently pressed.
*/
private suspend fun AwaitPointerEventScope.consumeUntilUp() {
do {
val event = awaitPointerEvent()
event.changes.fastForEach { it.consume() }
} while (event.changes.fastAny { it.pressed })
}
/**
* Waits for [ViewConfiguration.doubleTapTimeoutMillis] for a second press event. If a
* second press event is received before the time out, it is returned or `null` is returned
* if no second press is received.
*/
private suspend fun AwaitPointerEventScope.awaitSecondDown(
firstUp: PointerInputChange
): PointerInputChange? = withTimeoutOrNull(viewConfiguration.doubleTapTimeoutMillis) {
val minUptime = firstUp.uptimeMillis + viewConfiguration.doubleTapMinTimeMillis
var change: PointerInputChange
// The second tap doesn't count if it happens before DoubleTapMinTime of the first tap
do {
change = awaitFirstDown()
} while (change.uptimeMillis < minUptime)
change
}
/**
* [detectTapGestures]'s implementation of [PressGestureScope].
*/
private class PressGestureScopeImpl(
density: Density
) : PressGestureScope, Density by density {
private var isReleased = false
private var isCanceled = false
private val mutex = Mutex(locked = false)
/**
* Called when a gesture has been canceled.
*/
fun cancel() {
isCanceled = true
mutex.unlock()
}
/**
* Called when all pointers are up.
*/
fun release() {
isReleased = true
mutex.unlock()
}
/**
* Called when a new gesture has started.
*/
suspend fun reset() {
mutex.lock()
isReleased = false
isCanceled = false
}
override suspend fun awaitRelease() {
if (!tryAwaitRelease()) {
throw GestureCancellationException("The press gesture was canceled.")
}
}
override suspend fun tryAwaitRelease(): Boolean {
if (!isReleased && !isCanceled) {
mutex.lock()
}
return isReleased
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/gestures/model/HitType.kt
================================================
package ovh.plrapps.mapcompose.ui.gestures.model
enum class HitType {
Click, LongPress
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/layout/MinimumScaleMode.kt
================================================
package ovh.plrapps.mapcompose.ui.layout
sealed class MinimumScaleMode
/**
* Limit the minimum scale to no less than what would be required to fit inside the container.
* This is the default mode.
*/
data object Fit : MinimumScaleMode()
/**
* Limit the minimum scale to no less than what would be required to fill the container.
*/
data object Fill : MinimumScaleMode()
/**
* Force a specific minimum scale.
*/
data class Forced(val scale: Double) : MinimumScaleMode()
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/layout/Rendering.kt
================================================
package ovh.plrapps.mapcompose.ui.layout
/* We assume no device has a screen wider than this */
const val grid = 65536
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/layout/ZoomPanRotate.kt
================================================
package ovh.plrapps.mapcompose.ui.layout
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.CoroutineScope
import ovh.plrapps.mapcompose.ui.gestures.detectTransformGestures
import ovh.plrapps.mapcompose.ui.gestures.detectTapGestures
@Composable
internal fun ZoomPanRotate(
modifier: Modifier = Modifier,
gestureListener: GestureListener,
layoutSizeChangeListener: LayoutSizeChangeListener,
content: @Composable () -> Unit
) {
val scope = rememberCoroutineScope()
val flingSpec = rememberSplineBasedDecay()
Layout(
content = content,
modifier
.pointerInput(gestureListener.isListeningForGestures()) {
if (!gestureListener.isListeningForGestures()) return@pointerInput
detectTransformGestures(
onGesture = { centroid, pan, gestureZoom, gestureRotate ->
gestureListener.onRotationDelta(gestureRotate)
gestureListener.onScaleRatio(gestureZoom.toDouble(), centroid)
gestureListener.onScrollDelta(pan)
},
onTouchDown = gestureListener::onTouchDown,
onTwoFingersTap = gestureListener::onTwoFingersTap,
onFling = { velocity -> gestureListener.onFling(flingSpec, velocity) },
onFlingZoom = { centroid, velocity ->
gestureListener.onFlingZoom(velocity, centroid)
}
)
}
.pointerInput(gestureListener.isListeningForGestures()) {
if (!gestureListener.isListeningForGestures()) return@pointerInput
detectTapGestures(
onTap = { offset -> gestureListener.onTap(offset) },
onDoubleTap = { offset -> gestureListener.onDoubleTap(offset) },
onDoubleTapZoom = { centroid, zoom ->
gestureListener.onScaleRatio(zoom.toDouble(), centroid)
},
onDoubleTapZoomFling = { centroid, velocity ->
gestureListener.onFlingZoom(velocity, centroid)
},
onPress = { gestureListener.onPress() },
onLongPress = { offset -> gestureListener.onLongPress(offset) },
shouldConsumeTap = { offset -> gestureListener.shouldConsumeTapGesture(offset) },
shouldConsumeLongPress = { offset ->
gestureListener.shouldConsumeLongPress(offset)
}
)
}
.onSizeChanged {
layoutSizeChangeListener.onSizeChanged(scope, it)
}
.fillMaxSize(),
) { measurables, constraints ->
val placeables = measurables.map { measurable ->
// Measure each children
measurable.measure(constraints)
}
// Set the size of the layout as big as it can
layout(constraints.maxWidth, constraints.maxHeight) {
// Place children in the parent layout
placeables.forEach { placeable ->
placeable.place(x = 0, y = 0)
}
}
}
}
internal interface GestureListener {
fun onScaleRatio(scaleRatio: Double, centroid: Offset)
fun onRotationDelta(rotationDelta: Float)
fun onScrollDelta(scrollDelta: Offset)
fun onFling(flingSpec: DecayAnimationSpec, velocity: Velocity)
fun onFlingZoom(velocity: Float, centroid: Offset)
fun onTouchDown()
fun onPress()
fun onTap(focalPt: Offset)
fun onDoubleTap(focalPt: Offset)
fun onTwoFingersTap(focalPt: Offset)
fun onLongPress(focalPt: Offset)
fun isListeningForGestures(): Boolean
fun shouldConsumeTapGesture(focalPt: Offset): Boolean
fun shouldConsumeLongPress(focalPt: Offset): Boolean
}
internal interface LayoutSizeChangeListener {
fun onSizeChanged(composableScope: CoroutineScope, size: IntSize)
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/markers/Clusterer.kt
================================================
package ovh.plrapps.mapcompose.ui.markers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ovh.plrapps.mapcompose.api.BoundingBox
import ovh.plrapps.mapcompose.api.ClusterScaleThreshold
import ovh.plrapps.mapcompose.api.MarkerDataSnapshot
import ovh.plrapps.mapcompose.api.VisibleArea
import ovh.plrapps.mapcompose.api.fullSize
import ovh.plrapps.mapcompose.api.maxScale
import ovh.plrapps.mapcompose.api.referentialSnapshotFlow
import ovh.plrapps.mapcompose.api.scrollTo
import ovh.plrapps.mapcompose.api.visibleArea
import ovh.plrapps.mapcompose.ui.state.MapState
import ovh.plrapps.mapcompose.ui.state.markers.MarkerRenderState
import ovh.plrapps.mapcompose.ui.state.markers.model.ClusterClickBehavior
import ovh.plrapps.mapcompose.ui.state.markers.model.ClusterInfo
import ovh.plrapps.mapcompose.ui.state.markers.model.Custom
import ovh.plrapps.mapcompose.ui.state.markers.model.Default
import ovh.plrapps.mapcompose.ui.state.markers.model.MarkerData
import ovh.plrapps.mapcompose.ui.state.markers.model.MarkerType
import ovh.plrapps.mapcompose.ui.state.markers.model.None
import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy
import ovh.plrapps.mapcompose.utils.contains
import ovh.plrapps.mapcompose.utils.dpToPx
import ovh.plrapps.mapcompose.utils.map
import ovh.plrapps.mapcompose.utils.throttle
import java.util.UUID
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.ceil
import kotlin.math.ln
import kotlin.math.pow
import kotlin.math.sqrt
internal class Clusterer(
val id: String,
clusteringThreshold: Dp,
private val mapState: MapState,
private val markerRenderState: MarkerRenderState,
markersDataFlow: MutableStateFlow>,
private val clusterClickBehavior: ClusterClickBehavior,
private val scaleThreshold: ClusterScaleThreshold,
private val clusterFactory: (ids: List) -> (@Composable () -> Unit)
) {
private val scope = CoroutineScope(
mapState.scope.coroutineContext + SupervisorJob(mapState.scope.coroutineContext[Job])
)
/* Create a derived state flow from the original unique source of truth */
private val markers = markersDataFlow.map(scope) {
it.filter { markerData ->
(markerData.renderingStrategy is RenderingStrategy.Clustering) &&
markerData.renderingStrategy.clustererId == id
}.map { markerData ->
Marker(markerData)
}
}
internal val exemptionSet = MutableStateFlow>(setOf())
private val referentialSnapshotFlow = mapState.referentialSnapshotFlow()
private val markersSnapshotFlow = snapshotFlow {
markerRenderState.getClusteredMarkers().map {
MarkerDataSnapshot(it.id, it.x, it.y)
}
}
private val clusterIdPrefix = "#cluster#-$id"
private val epsilon = dpToPx(clusteringThreshold.value)
init {
scope.launch {
// react on base data change
markers.throttle(100).collectLatest { markers ->
// react on marker move
markersSnapshotFlow.throttle(300).collectLatest {
// react on scale and scroll change
referentialSnapshotFlow.throttle(500).collectLatest {
val scale = it.scale
val padding = dpToPx(100f).toInt()
val visibleArea = mapState.visibleArea(IntOffset(padding, padding))
/* Get the list of rendered clusterer managed (by this clusterer) markers */
val markersOnMap =
markerRenderState.getClusteredMarkers().filter { markerData ->
(markerData.renderingStrategy is RenderingStrategy.Clustering) &&
markerData.renderingStrategy.clustererId == id
}
exemptionSet.collectLatest { exemptionSet ->
withContext(Dispatchers.Default) {
clusterize(
scale,
visibleArea,
markers,
markersOnMap,
exemptionSet,
epsilon
)
}
}
}
}
}
}
}
fun onPlaceableClick(clusterData: MarkerData) {
if (clusterData.type !is MarkerType.Cluster) return
val markersData = clusterData.type.markersData
when (clusterClickBehavior) {
is Custom -> {
clusterClickBehavior.onClick(
ClusterInfo(clusterData.x, clusterData.y, markersData)
)
if (clusterClickBehavior.withDefaultBehavior) {
defaultClusterClickListener(markersData)
}
}
Default -> {
defaultClusterClickListener(markersData)
}
None -> {
}
}
}
/**
* The user might want to cancel a clusterer while keeping managed markers. For example,
* removing a clusterer and adding it back with the same id but with a different cluster style.
* This allows for replacing a clusterer without any visual blinks.
*/
fun cancel(removeManaged: Boolean) {
scope.cancel()
if (removeManaged) {
markerRenderState.removeAllClusterManagedMarkers(id)
}
}
private suspend fun clusterize(
scale: Double,
visibleArea: VisibleArea,
markers: List,
markersOnMap: List,
exemptionSet: Set,
epsilon: Float
) = coroutineScope {
val visibleMarkers = markers.filter { marker ->
visibleArea.contains(marker.x, marker.y) && marker.id !in exemptionSet
}
val exempted = markers.filter { marker ->
marker.id in exemptionSet
}
/* Disable clustering if scale is greater than the threshold */
val maxScale = when (scaleThreshold) {
is ClusterScaleThreshold.FixedScale -> scaleThreshold.scale
ClusterScaleThreshold.MaxScale -> mapState.maxScale
}
val result = if (scale < maxScale) {
val densitySearchPass = processMarkers(markers, visibleMarkers, scale, epsilon)
mergeClosest(densitySearchPass, epsilon, scale)
} else {
ClusteringResult(markers = visibleMarkers)
}
withContext(Dispatchers.Main) {
render(markersOnMap, result.clusters, result.markers + exempted)
}
}
private fun render(
markersOnMap: List,
clusters: List,
markers: List
) {
val clustersById = clusters.associateByTo(mutableMapOf()) { it.id }
val markersById = markers.associateByTo(mutableMapOf()) { it.uuid }
val clusterIds = mutableListOf()
val markerIds = mutableListOf()
markersOnMap.forEach { markerData ->
if (markerData.id.startsWith(clusterIdPrefix)) {
clusterIds.add(markerData.id)
val inMemory = clustersById[markerData.id]
if (inMemory == null) {
markerRenderState.removeClustererManagedMarker(markerData.id)
} else {
if (inMemory.x != markerData.x || inMemory.y != markerData.y) {
mapState.markerState.moveMarkerTo(markerData, inMemory.x, inMemory.y)
}
}
} else { // then it must be a marker
if (shouldProcessMarker(markerData)) {
markerIds.add(markerData.uuid)
val inMemory = markersById[markerData.uuid]
if (inMemory == null) {
markerRenderState.removeClustererManagedMarker(markerData.id)
} else {
if (inMemory.x != markerData.x || inMemory.y != markerData.y) {
mapState.markerState.moveMarkerTo(markerData, inMemory.x, inMemory.y)
}
}
}
}
}
clustersById.entries.forEach {
if (it.key !in clusterIds) {
it.value.addToMap()
}
}
markersById.entries.forEach {
if (it.key !in markerIds) {
it.value.addToMap()
}
}
}
private fun processMarkers(
markers: List, visibleMarkers: List, scale: Double, epsilon: Float
): ClusteringResult {
val snapScale = getSnapScale(scale)
val mesh = Mesh(epsilon, snapScale, mapState.fullSize)
visibleMarkers.forEach { marker ->
mesh.add(marker)
}
return findNewClustersByDensity(markers, mesh, scale, epsilon)
}
private fun findNewClustersByDensity(
markers: List,
mesh: Mesh,
scale: Double,
epsilon: Float,
): ClusteringResult {
/* Compute density for each window */
mesh.gridMap.keys.forEach { key ->
val neighbors = mesh.getNeighbors(key)
val window = mesh.gridMap[key]
if (window != null) {
window.density = window.markers.size + neighbors.sumOf { it.markers.size }
}
}
val entriesSorted = mesh.gridMap.entries.sortedByDescending {
it.value.density
}
val clusterList = mutableListOf()
val markerList = mutableListOf()
val markerAssigned = markers.associateTo(mutableMapOf()) {
it.uuid to false
}
for (e in entriesSorted) {
val neighbors = mesh.getNeighbors(e.key)
val neighborsMarkers = neighbors.flatMap {
it.markers
}
val startBary = getBarycenter(e.value.markers) ?: break
val mergedMarkers = (e.value.markers + neighborsMarkers).filter { marker ->
distance(startBary, marker, scale) < epsilon && (markerAssigned[marker.uuid]
?: false).not()
}.onEach {
markerAssigned[it.uuid] = true
}
if (mergedMarkers.size == 1) {
markerList.add(mergedMarkers.first())
continue
}
val cluster = mergedMarkers.toCluster()
if (cluster.markers.isNotEmpty()) {
clusterList.add(cluster)
}
}
return ClusteringResult(clusterList, markerList)
}
private tailrec fun mergeClosest(
result: ClusteringResult,
epsilon: Float,
scale: Double
): ClusteringResult {
fun findInVicinity(cluster: Cluster): Placeable? {
val closeEnoughMarker = result.markers.firstOrNull {
distance(cluster.x, cluster.y, it.x, it.y, scale) < epsilon
}
return closeEnoughMarker ?: result.clusters.firstOrNull { otherCluster ->
distance(otherCluster.x, otherCluster.y, cluster.x, cluster.y, scale) < epsilon
&& otherCluster != cluster
}
}
for (cluster in result.clusters) {
val inVicinity = findInVicinity(cluster)
if (inVicinity != null) {
return when (inVicinity) {
is Cluster -> {
val fusedCluster = fuseClusters(cluster, inVicinity)
val newClusterList = result.clusters.filter {
it != cluster && it != inVicinity
} + fusedCluster
mergeClosest(
ClusteringResult(newClusterList, result.markers),
epsilon,
scale
)
}
is Marker -> {
val fusedCluster = cluster.addMarker(inVicinity)
val newClusterList = result.clusters.filter {
it != cluster
} + fusedCluster
val newMarkerList = result.markers.filter {
it != inVicinity
}
mergeClosest(
ClusteringResult(newClusterList, newMarkerList),
epsilon,
scale
)
}
}
}
}
return result
}
private fun getBarycenter(markers: List): Barycenter? {
if (markers.isEmpty()) return null
return Barycenter(
x = markers.sumOf { it.x } / markers.size,
y = markers.sumOf { it.y } / markers.size,
weight = markers.size
)
}
private fun distance(b: Barycenter, marker: Marker, scale: Double): Double {
return distance(b.x, b.y, marker.x, marker.y, scale)
}
private fun distance(x1: Double, y1: Double, x2: Double, y2: Double, scale: Double): Double {
return sqrt(
(abs(x1 - x2) * mapState.fullSize.width * scale).pow(2) +
(abs(y1 - y2) * mapState.fullSize.height * scale).pow(2),
)
}
private fun fuseClusters(cluster1: Cluster, cluster2: Cluster): Cluster {
val newMarkers = cluster1.markers + cluster2.markers
return newMarkers.toCluster()
}
private fun Cluster.addMarker(marker: Marker): Cluster {
val newMarkers = markers + marker
return newMarkers.toCluster()
}
private fun List.toCluster(): Cluster {
return Cluster(
clusterIdPrefix = clusterIdPrefix,
x = sumOf { it.x } / size,
y = sumOf { it.y } / size,
markers = this
)
}
private fun Cluster.addToMap() {
val markersData = markers.map { it.markerData }
val markerData = makeClusterMarkerData(id, x, y, markersData) {
clusterFactory(markers.map { it.id })()
}
markerRenderState.addClustererManagedMarker(markerData)
}
private fun defaultClusterClickListener(markers: List) {
if (markers.isEmpty()) return
/* Compute the bounding box */
var minX: Double = Double.MAX_VALUE
var maxX: Double = Double.MIN_VALUE
var minY: Double = Double.MAX_VALUE
var maxY: Double = Double.MIN_VALUE
markers.forEach {
minX = if (it.x < minX) it.x else minX
maxX = if (it.x > maxX) it.x else maxX
minY = if (it.y < minY) it.y else minY
maxY = if (it.y > maxY) it.y else maxY
}
val bb = BoundingBox(minX, minY, maxX, maxY)
scope.launch {
mapState.scrollTo(bb, padding = Offset(0.2f, 0.2f))
}
}
private fun shouldProcessMarker(markerData: MarkerData): Boolean {
return (markerData.renderingStrategy is RenderingStrategy.Clustering) &&
markerData.renderingStrategy.clustererId == id
}
private fun getSnapScale(scale: Double): Double = 2.0.pow(ceil(ln(scale) / ln(2.0)))
private fun Marker.addToMap() {
markerRenderState.addClustererManagedMarker(markerData)
}
private fun makeClusterMarkerData(
id: String,
x: Double,
y: Double,
markersData: List,
c: @Composable () -> Unit
): MarkerData {
return MarkerData(
id, x, y,
relativeOffset = Offset(-0.5f, -0.5f),
absoluteOffset = DpOffset.Zero,
zIndex = markersData.maxOfOrNull { it.zIndex } ?: 0f,
isConstrainedInBounds = true,
clickableAreaScale = Offset(1f, 1f),
clickableAreaCenterOffset = Offset(0f, 0f),
clickable = true,
renderingStrategy = RenderingStrategy.Clustering(this@Clusterer.id),
type = MarkerType.Cluster(clustererId = this@Clusterer.id, markersData),
c = c
)
}
private data class Barycenter(val x: Double, val y: Double, val weight: Int)
private data class ClusteringResult(
val clusters: List = emptyList(),
val markers: List = emptyList()
)
}
private class Mesh(
private val meshSize: Float,
private val scale: Double,
private val fullSize: IntSize,
) {
val gridMap = mutableMapOf()
val markers = mutableListOf()
private fun getKey(marker: Marker, meshSize: Float, scale: Double): Key {
val relativeWidth = marker.x * fullSize.width * scale
val relativeHeight = marker.y * fullSize.height * scale
return Key(
row = (relativeWidth / meshSize).toInt(),
col = (relativeHeight / meshSize).toInt()
)
}
fun add(marker: Marker) {
val key = getKey(marker, meshSize, scale)
val window = gridMap[key]
if (window == null) {
gridMap[key] = MarkerWindow(mutableListOf(marker))
} else {
window.markers.add(marker)
}
markers.add(marker)
}
fun getNeighbors(key: Key): List {
val neighborKeys = listOf(
Key(key.row - 1, key.col - 1),
Key(key.row - 1, key.col),
Key(key.row - 1, key.col + 1),
Key(key.row, key.col - 1),
Key(key.row, key.col + 1),
Key(key.row + 1, key.col - 1),
Key(key.row + 1, key.col),
Key(key.row + 1, key.col + 1),
)
return neighborKeys.mapNotNull {
gridMap[it]
}
}
}
private data class MarkerWindow(
val markers: MutableList,
var density: Int = 0
)
private data class Key(val row: Int, val col: Int)
private sealed interface Placeable
/**
* A marker can belong to one and only one cluster.
*/
private data class Marker(
val markerData: MarkerData,
) : Placeable {
val uuid: UUID
get() = markerData.uuid
val id: String
get() = markerData.id
val x: Double
get() = markerData.x
val y: Double
get() = markerData.y
}
private data class Cluster(
val clusterIdPrefix: String,
val x: Double,
val y: Double,
val markers: List
) : Placeable {
val id = buildString {
append(clusterIdPrefix)
/* Now we produce a hash based on the ids of the markers, and this hash *does not* depend
* on the order of the markers. */
val hashes = LongArray(markers.size)
for (i in markers.indices) {
hashes[i] = markers[i].id.hashCode().toLong()
}
hashes.sort()
var h = 0L
for (value in hashes) {
h = 31 * h + value
}
append(h.absoluteValue.toString(16))
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/markers/LazyLoader.kt
================================================
package ovh.plrapps.mapcompose.ui.markers
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import ovh.plrapps.mapcompose.api.referentialSnapshotFlow
import ovh.plrapps.mapcompose.api.visibleArea
import ovh.plrapps.mapcompose.ui.state.MapState
import ovh.plrapps.mapcompose.ui.state.markers.MarkerRenderState
import ovh.plrapps.mapcompose.ui.state.markers.model.MarkerData
import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy
import ovh.plrapps.mapcompose.utils.contains
import ovh.plrapps.mapcompose.utils.dpToPx
import ovh.plrapps.mapcompose.utils.map
import ovh.plrapps.mapcompose.utils.throttle
import java.util.*
internal class LazyLoader(
private val id: String,
private val mapState: MapState,
private val markerRenderState: MarkerRenderState,
markersDataFlow: MutableStateFlow>,
private val padding: Dp,
scope: CoroutineScope
) {
private val referentialSnapshotFlow = mapState.referentialSnapshotFlow()
private val job: Job
/* Create a derived state flow from the original unique source of truth */
private val markers = markersDataFlow.map(scope) {
it.filter { markerData ->
(markerData.renderingStrategy is RenderingStrategy.LazyLoading)
&& markerData.renderingStrategy.lazyLoaderId == id
}
}
init {
job = scope.launch {
markers.throttle(100).collectLatest {
referentialSnapshotFlow.throttle(100).collectLatest {
val padding = dpToPx(padding.value).toInt()
val visibleArea = mapState.visibleArea(IntOffset(padding, padding))
/* Get the list of lazy loaded markers */
val markersOnMap =
markerRenderState.getLazyLoadedMarkers().filter { markerData ->
(markerData.renderingStrategy is RenderingStrategy.LazyLoading)
&& markerData.renderingStrategy.lazyLoaderId == id
}
val visibleMarkers = withContext(Dispatchers.Default) {
markers.value.filter { dataSnapshot ->
visibleArea.contains(dataSnapshot.x, dataSnapshot.y)
}
}
render(markersOnMap, visibleMarkers)
}
}
}
}
private fun render(
markersOnMap: List,
markers: List
) {
val markersById = markers.associateByTo(mutableMapOf()) { it.uuid }
val markerIds = mutableListOf()
markersOnMap.forEach { markerData ->
markerIds.add(markerData.uuid)
val inMemory = markersById[markerData.uuid]
if (inMemory == null) {
markerRenderState.removeLazyLoadedMarker(markerData.id)
} else {
if (inMemory.x != markerData.x || inMemory.y != markerData.y) {
mapState.markerState.moveMarkerTo(markerData, inMemory.x, inMemory.y)
}
}
}
markersById.entries.forEach {
if (it.key !in markerIds) {
markerRenderState.addLazyLoadedMarker(it.value)
}
}
}
fun cancel(removeManaged: Boolean) {
job.cancel()
if (removeManaged) {
markerRenderState.removeAllLazyLoadedMarkers(id)
}
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/markers/MarkerComposer.kt
================================================
package ovh.plrapps.mapcompose.ui.markers
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layoutId
import ovh.plrapps.mapcompose.api.moveMarkerBy
import ovh.plrapps.mapcompose.ui.state.markers.model.MarkerData
import ovh.plrapps.mapcompose.ui.state.MapState
import ovh.plrapps.mapcompose.ui.state.markers.MarkerRenderState
import ovh.plrapps.mapcompose.ui.state.ZoomPanRotateState
import ovh.plrapps.mapcompose.utils.rotateX
import ovh.plrapps.mapcompose.utils.rotateY
import ovh.plrapps.mapcompose.utils.toRad
@Composable
internal fun MarkerComposer(
modifier: Modifier,
zoomPRState: ZoomPanRotateState,
markerRenderState: MarkerRenderState,
mapState: MapState
) {
MarkerLayout(
modifier = modifier,
zoomPRState = zoomPRState,
) {
for (data in markerRenderState.markers.value) {
/* Optimize re-compositions */
key(data.uuid) {
Box(
Modifier
.layoutId(data)
.then(
if (data.isDraggable) {
Modifier.pointerInput(Unit) {
detectDragGestures(
onDragStart = {
val listener = data.dragStartListener
if (listener != null) {
invokeDragStartListener(data, zoomPRState, it)
}
},
onDragEnd = {
data.dragEndListener?.onDragEnd(data.id, data.x, data.y)
}
) { change, dragAmount ->
change.consume()
val interceptor = data.dragInterceptor
if (interceptor != null) {
invokeDragInterceptor(
data,
zoomPRState,
dragAmount,
change.position
)
} else {
mapState.moveMarkerBy(data.id, dragAmount)
}
}
}
} else Modifier
)
) {
data.c()
}
}
}
for (data in markerRenderState.callouts.values) {
/* Optimize re-compositions */
key(data.markerData.uuid) {
Box(
Modifier
.layoutId(data.markerData)
.then(
if (data.markerData.isClickable) {
/**
* As of 2022/04, using Modifier.clickable causes a huge performance
* drop when the number of callouts exceeds a few dozens.
* Using pointerInput, we loose the ripple effect.
*/
Modifier.pointerInput(Unit) {
detectTapGestures(
onTap = {
markerRenderState.onCalloutClick(data.markerData)
}
)
}
} else Modifier
)
) {
data.markerData.c()
}
}
}
}
}
private fun invokeDragStartListener(
data: MarkerData,
zoomPRState: ZoomPanRotateState,
position: Offset
) {
/* Compute the pointer offset */
val origin = Offset(- data.measuredWidth * data.relativeOffset.x, - data.measuredHeight * data.relativeOffset.y)
val pointerOffset = position - origin
val angle = -zoomPRState.rotation.toRad()
val pointerOffsetRotated = Offset(
rotateX(pointerOffset.x.toDouble(), pointerOffset.y.toDouble(), angle).toFloat(),
rotateY(pointerOffset.x.toDouble(), pointerOffset.y.toDouble(), angle).toFloat()
)
val px = data.x + pointerOffsetRotated.x.toDouble() / (zoomPRState.fullWidth * zoomPRState.scale)
val py = data.y + pointerOffsetRotated.y.toDouble() / (zoomPRState.fullHeight * zoomPRState.scale)
data.dragStartListener?.onDragStart(
id = data.id,
x = data.x,
y = data.y,
px = if (data.isConstrainedInBounds) px.coerceIn(0.0, 1.0) else px,
py = if (data.isConstrainedInBounds) py.coerceIn(0.0, 1.0) else py
)
}
private fun invokeDragInterceptor(
data: MarkerData,
zoomPRState: ZoomPanRotateState,
deltaPx: Offset,
position: Offset
) {
/* Compute the displacement */
val angle = -zoomPRState.rotation.toRad()
val dx = rotateX(deltaPx.x.toDouble(), deltaPx.y.toDouble(), angle)
val dy = rotateY(deltaPx.x.toDouble(), deltaPx.y.toDouble(), angle)
val deltaX = dx / (zoomPRState.fullWidth * zoomPRState.scale)
val deltaY = dy / (zoomPRState.fullHeight * zoomPRState.scale)
/* Compute the pointer offset */
val origin = Offset(- data.measuredWidth * data.relativeOffset.x, - data.measuredHeight * data.relativeOffset.y)
val pointerOffset = position - origin
val pointerOffsetRotated = Offset(
rotateX(pointerOffset.x.toDouble(), pointerOffset.y.toDouble(), angle).toFloat(),
rotateY(pointerOffset.x.toDouble(), pointerOffset.y.toDouble(), angle).toFloat()
)
val px = data.x + pointerOffsetRotated.x.toDouble() / (zoomPRState.fullWidth * zoomPRState.scale)
val py = data.y + pointerOffsetRotated.y.toDouble() / (zoomPRState.fullHeight * zoomPRState.scale)
data.dragInterceptor?.onMove(
id = data.id,
x = data.x,
y = data.y,
dx = deltaX,
dy = deltaY,
px = if (data.isConstrainedInBounds) px.coerceIn(0.0, 1.0) else px,
py = if (data.isConstrainedInBounds) py.coerceIn(0.0, 1.0) else py
)
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/markers/MarkerLayout.kt
================================================
package ovh.plrapps.mapcompose.ui.markers
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import ovh.plrapps.mapcompose.ui.layout.grid
import ovh.plrapps.mapcompose.ui.state.ZoomPanRotateState
import ovh.plrapps.mapcompose.ui.state.markers.model.MarkerData
import ovh.plrapps.mapcompose.utils.rotateCenteredX
import ovh.plrapps.mapcompose.utils.rotateCenteredY
import ovh.plrapps.mapcompose.utils.toRad
import kotlin.math.ceil
@Composable
internal fun MarkerLayout(
modifier: Modifier,
zoomPRState: ZoomPanRotateState,
content: @Composable () -> Unit
) {
/* Scroll values may not be represented accurately using floats (a float has 7 significant
* decimal digits, so any number above ~10M isn't represented accurately).
* Since the translate function of the Canvas works with floats, we perform a change of
* referential so that we only need to translate the canvas by an amount which can be
* precisely represented as a float. */
val origin by remember {
derivedStateOf {
IntOffset(
((ceil(zoomPRState.scrollX / grid) * grid)).toInt(),
((ceil(zoomPRState.scrollY / grid) * grid)).toInt()
)
}
}
val density = LocalDensity.current
Layout(
content = content,
modifier
.graphicsLayer {
translationX = (-zoomPRState.scrollX + origin.x).toFloat()
translationY = (-zoomPRState.scrollY + origin.y).toFloat()
}
.background(Color.Transparent)
.fillMaxSize()
) { measurables, constraints ->
val placeableCst = constraints.copy(minHeight = 0, minWidth = 0)
layout(constraints.maxWidth, constraints.maxHeight) {
for (measurable in measurables) {
val data = measurable.layoutId as? MarkerData ?: continue
/* Don't layout markers which are way out of display bounds, as it can can cause
* jitter in marker rendering. */
if (data.isOutOfDisplay()) continue
val placeable = measurable.measure(placeableCst)
data.measuredWidth = placeable.measuredWidth
data.measuredHeight = placeable.measuredHeight
val widthOffset =
placeable.measuredWidth * data.relativeOffset.x + with(density) { data.absoluteOffset.x.toPx() }
val heightOffset =
placeable.measuredHeight * data.relativeOffset.y + with(density) { data.absoluteOffset.y.toPx() }
if (zoomPRState.rotation == 0f) {
val x = data.x * zoomPRState.fullWidth * zoomPRState.scale + widthOffset
val y = data.y * zoomPRState.fullHeight * zoomPRState.scale + heightOffset
/* It's important to always update data even when visibility is set to false, so
* click handling works on updated data (a non-visible marker might be clickable) */
data.xPlacement = x
data.yPlacement = y
if (data.isVisible) {
placeable.place((x - origin.x).toInt(), (y - origin.y).toInt(), zIndex = data.zIndex)
}
} else {
with(zoomPRState) {
val angleRad = rotation.toRad()
val xFullPx = data.x * fullWidth * scale
val yFullPx = data.y * fullHeight * scale
val centerX = centroidX * fullWidth * scale
val centerY = centroidY * fullHeight * scale
val x = rotateCenteredX(
xFullPx,
yFullPx,
centerX,
centerY,
angleRad
) + widthOffset
val y = rotateCenteredY(
xFullPx,
yFullPx,
centerX,
centerY,
angleRad
) + heightOffset
/* It's important to always update data even when visibility is set to false,
* so click handling works on updated data (a non-visible marker might be
* clickable) */
data.xPlacement = x
data.yPlacement = y
if (data.isVisible) {
placeable.place(
(x - origin.x).toInt(),
(y - origin.y).toInt(),
zIndex = data.zIndex
)
}
}
}
}
}
}
}
private fun MarkerData.isOutOfDisplay() = x < -1.0 || x > 2.0 || y < -1.0 || y > 2.0
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/paths/PathComposer.kt
================================================
package ovh.plrapps.mapcompose.ui.paths
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.graphics.Path
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import ovh.plrapps.mapcompose.ui.layout.grid
import ovh.plrapps.mapcompose.ui.paths.model.Cap
import ovh.plrapps.mapcompose.ui.paths.model.PatternItem
import ovh.plrapps.mapcompose.ui.state.DrawablePathState
import ovh.plrapps.mapcompose.ui.state.PathState
import ovh.plrapps.mapcompose.ui.state.ZoomPanRotateState
import ovh.plrapps.mapcompose.utils.Point
import kotlin.math.abs
import kotlin.math.ceil
@Composable
internal fun PathComposer(
modifier: Modifier,
zoomPRState: ZoomPanRotateState,
pathState: PathState
) {
var drawOrder = 0
for (path in pathState.pathState.values.sortedBy { it.zIndex }) {
key(path.id) {
path.drawOrder.update { drawOrder++ }
PathCanvas(modifier, zoomPRState, path)
}
}
}
@Composable
internal fun PathCanvas(
modifier: Modifier,
zoomPRState: ZoomPanRotateState,
drawablePathState: DrawablePathState
) {
val offsetAndCount = drawablePathState.offsetAndCount
val pathData = drawablePathState.pathData
/* Scroll values may not be represented accurately using floats (a float has 7 significant
* decimal digits, so any number above ~10M isn't represented accurately).
* Since the translate function of the Canvas works with floats, we perform a change of
* referential so that we only need to translate the canvas by an amount which can be
* precisely represented as a float.
* For paths, we also need to be mindful not to change the referential too often. */
val origin by produceState(
initialValue = IntOffset.Zero,
key1 = zoomPRState.scale,
key2 = zoomPRState.scrollX,
key3 = zoomPRState.scrollY
) {
val scale = zoomPRState.scale
val formerX0 = value.x
val formerY0 = value.y
val x0 = ((ceil(zoomPRState.scrollX / grid) * grid) / scale).toInt()
val y0 = ((ceil(zoomPRState.scrollY / grid) * grid) / scale).toInt()
val shouldUpdate = (abs(x0 - formerX0) * scale > grid) ||
(abs(y0 - formerY0) * scale > grid)
if (shouldUpdate) {
value = IntOffset(x0, y0)
}
}
/* When epsilon changes, a new path is generated. */
val epsilon by remember {
derivedStateOf {
val scale = zoomPRState.scale
val simplify = drawablePathState.simplify
if (simplify == 0f) {
0.0
} else {
simplify / scale
}
}
}
val pathWithOrigin by produceState(
/* Only affects the very first value.
* During the computation of a new value, the state holds the last computed value. */
initialValue = null,
keys = arrayOf(
pathData,
offsetAndCount,
epsilon,
origin,
drawablePathState.simplify
)
) {
val x0 = origin.x
val y0 = origin.y
val ep = epsilon
value = withContext(Dispatchers.Default) {
generatePath(
pathData = pathData,
offset = offsetAndCount.x,
count = offsetAndCount.y,
epsilon = ep,
x0 = x0,
y0 = y0,
onNewDecimatedPath = { drawablePathState.currentDecimatedPath.value = it }
)
}
}
val path = pathWithOrigin ?: return
val widthPx = with(LocalDensity.current) {
drawablePathState.width.toPx()
}
val density = LocalDensity.current
val dashPathEffect = remember(drawablePathState.pattern, widthPx, zoomPRState.scale, density) {
drawablePathState.pattern?.let {
makePathEffect(it, strokeWidthPx = widthPx, scale = zoomPRState.scale.toFloat(), density)
}
}
val paint = remember(
dashPathEffect,
drawablePathState.color,
drawablePathState.cap,
widthPx,
zoomPRState.scale
) {
Paint().apply {
style = Paint.Style.STROKE
strokeJoin = Paint.Join.ROUND
this.color = drawablePathState.color.toArgb()
strokeCap = when (drawablePathState.cap) {
Cap.Butt -> Paint.Cap.BUTT
Cap.Round -> Paint.Cap.ROUND
Cap.Square -> Paint.Cap.SQUARE
}
pathEffect = dashPathEffect
strokeWidth = (widthPx / zoomPRState.scale).toFloat()
}
}
val fillPaint = remember(
drawablePathState.fillColor,
) {
Paint().apply {
style = Paint.Style.FILL
this.color = drawablePathState.fillColor?.toArgb() ?: Color.Transparent.toArgb()
}
}
Canvas(
modifier = modifier
.fillMaxSize()
.background(Color.Transparent)
) {
withTransform({
/* Geometric transformations seem to be applied in reversed order of declaration */
rotate(
degrees = zoomPRState.rotation,
pivot = Offset(
x = (zoomPRState.pivotX).toFloat(),
y = (zoomPRState.pivotY).toFloat()
)
)
translate(
left = (-zoomPRState.scrollX + path.origin.x * zoomPRState.scale).toFloat(),
top = (-zoomPRState.scrollY + path.origin.y * zoomPRState.scale).toFloat()
)
scale(scale = zoomPRState.scale.toFloat(), Offset.Zero)
}) {
with(drawablePathState) {
if (visible) {
drawIntoCanvas {
if (drawablePathState.fillColor != null) {
it.nativeCanvas.drawPath(path.path, fillPaint)
}
it.nativeCanvas.drawPath(path.path, paint)
}
}
}
}
}
}
/**
* Once an instance of [PathData] is created, [data] shall not have structural modifications for
* subList to work (see [List.subList] doc). */
class PathData internal constructor(
internal val data: List,
internal val boundingBox: Pair // topLeft, bottomRight
) {
val size: Int
get() = data.size
}
@Suppress("unused")
class PathDataBuilder internal constructor(
private val fullWidth: Int,
private val fullHeight: Int
) {
private val points = mutableListOf()
private var xMin: Double? = null
private var xMax: Double? = null
private var yMin: Double? = null
private var yMax: Double? = null
/**
* Add a point to the path. Values are relative coordinates (in range [0f..1f]).
*/
@Synchronized
fun addPoint(x: Double, y: Double) = apply {
points.add(createPoint(x, y))
}
/**
* Add points to the path. Values are relative coordinates (in range [0f..1f]).
*/
@Synchronized
fun addPoints(points: List>) = apply {
this.points += points.map { (x, y) -> createPoint(x, y) }
}
private fun createPoint(x: Double, y: Double): Point {
return Point(x * fullWidth, y * fullHeight).also {
updateBoundingBox(it.x, it.y)
}
}
private fun updateBoundingBox(x: Double, y: Double) {
xMin = xMin?.coerceAtMost(x) ?: x
xMax = xMax?.coerceAtLeast(x) ?: x
yMin = yMin?.coerceAtMost(y) ?: y
yMax = yMax?.coerceAtLeast(y) ?: y
}
@Synchronized
fun build(): PathData? {
/* If there is only one point, the path has no sense */
if (points.size < 2) return null
val _xMin = xMin
val _xMax = xMax
val _yMin = yMin
val _yMax = yMax
val bb = if (_xMin != null && _xMax != null && _yMin != null && _yMax != null) {
Pair(Point(_xMin, _yMin), Point(_xMax, _yMax))
} else return null
/**
* Make a defensive copy (see PathData doc). We don't want structural modifications to
* [points] to be visible from the [PathData] instance. */
return PathData(points.toList(), bb)
}
}
private fun generatePath(
pathData: PathData,
offset: Int,
count: Int,
epsilon: Double,
x0: Int,
y0: Int,
onNewDecimatedPath: (decimatedPath: List) -> Unit
): PathWithOrigin {
val p = Path()
val subList = pathData.data.subList(offset, offset + count)
val toRender = if (epsilon > 0f) {
runCatching {
val out = mutableListOf()
ramerDouglasPeucker(subList, epsilon, out)
onNewDecimatedPath(out)
out
}.getOrElse {
subList
}
} else subList
for ((i, point) in toRender.withIndex()) {
if (i == 0) {
p.moveTo((point.x - x0).toFloat(), (point.y - y0).toFloat())
} else {
p.lineTo((point.x - x0).toFloat(), (point.y - y0).toFloat())
}
}
return PathWithOrigin(p, IntOffset(x0, y0))
}
internal fun makePathEffect(
pattern: List,
strokeWidthPx: Float,
scale: Float,
density: Density
): DashPathEffect? {
val data = makeIntervals(pattern, strokeWidthPx, scale, density) ?: return null
return DashPathEffect(data.intervals, data.phase)
}
internal fun concatGap(pattern: List): List {
return buildList {
var gap = 0.dp
for (item in pattern) {
if (item is PatternItem.Gap) {
gap += item.length
} else {
if (gap.value > 0f) {
add(PatternItem.Gap(gap))
}
gap = 0.dp
add(item)
}
}
if (gap.value > 0f) {
add(PatternItem.Gap(gap))
}
}
}
internal fun makeIntervals(
pattern: List,
strokeWidthPx: Float,
scale: Float,
density: Density
): DashPathEffectData? {
if (pattern.isEmpty()) return null
// First, concat gaps
val concat = concatGap(pattern)
var phase = 0f
val firstItem = concat.firstOrNull() ?: return null
val trimmed = if (firstItem is PatternItem.Gap) {
phase = with(density) { firstItem.length.toPx() }
/* If first item is a gap, remember it as phase and move it to then end of the pattern and
* re-concat since the original last item may also be a gap. */
concatGap(concat.subList(1, concat.size) + firstItem)
} else {
concat
}
// If the pattern only contained a gap, ignore the pattern
if (trimmed.isEmpty()) return null
fun MutableList.addOffInterval(prev: PatternItem) {
if (prev is PatternItem.Gap) {
add((strokeWidthPx + with(density) { prev.length.toPx() }) / scale)
} else {
add(strokeWidthPx / scale)
}
}
val intervals: FloatArray = buildList {
var previousItem: PatternItem? = null
// At this stage, trimmed starts either with a Dot or a Dash
for (item in trimmed) {
val toAdd = when (item) {
is PatternItem.Dash -> with(density) { item.length.toPx() } / scale
PatternItem.Dot -> 1f
is PatternItem.Gap -> null
}
if (toAdd != null) {
/* If previous item isn't null, then we're adding a value at an odd index */
previousItem?.also { prev ->
addOffInterval(prev)
}
add(toAdd)
}
previousItem = item
}
previousItem?.also { prev ->
addOffInterval(prev)
}
}.toFloatArray()
return DashPathEffectData(intervals, phase)
}
private data class PathWithOrigin(val path: Path, val origin: IntOffset)
internal class DashPathEffectData(val intervals: FloatArray, val phase: Float)
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/paths/RamerDouglaPeucker.kt
================================================
package ovh.plrapps.mapcompose.ui.paths
import ovh.plrapps.mapcompose.utils.Point
import kotlin.math.hypot
internal fun ramerDouglasPeucker(pointList: List, epsilon: Double, out: MutableList) {
if (pointList.size < 2) throw IllegalArgumentException("Not enough points to simplify")
// Find the point with the maximum distance from line between start and end
var dmax = 0.0
var index = 0
val end = pointList.size - 1
for (i in 1 until end) {
val d = perpendicularDistance(pointList[i], pointList[0], pointList[end])
if (d > dmax) { index = i; dmax = d }
}
// If max distance is greater than epsilon, recursively simplify
if (dmax > epsilon) {
val recResults1 = mutableListOf()
val recResults2 = mutableListOf()
val firstLine = pointList.take(index + 1)
val lastLine = pointList.drop(index)
ramerDouglasPeucker(firstLine, epsilon, recResults1)
ramerDouglasPeucker(lastLine, epsilon, recResults2)
// build the result list
out.addAll(recResults1.take(recResults1.size - 1))
out.addAll(recResults2)
if (out.size < 2) throw RuntimeException("Problem assembling output")
}
else {
// Just return start and end points
out.clear()
out.add(pointList.first())
out.add(pointList.last())
}
}
private fun perpendicularDistance(pt: Point, lineStart: Point, lineEnd: Point): Double {
var dx = lineEnd.x - lineStart.x
var dy = lineEnd.y - lineStart.y
// Normalize
val mag = hypot(dx, dy)
if (mag > 0.0) { dx /= mag; dy /= mag }
val pvx = pt.x - lineStart.x
val pvy = pt.y - lineStart.y
// Get dot product (project pv onto normalized direction)
val pvdot = dx * pvx + dy * pvy
// Scale line direction vector and substract it from pv
val ax = pvx - pvdot * dx
val ay = pvy - pvdot * dy
return hypot(ax, ay)
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/paths/model/Cap.kt
================================================
package ovh.plrapps.mapcompose.ui.paths.model
enum class Cap {
/** The stroke ends with the path, and does not project beyond it. */
Butt,
/**
* The stroke projects out as a semicircle, with the center at the end of the path.
*/
Round,
/**
* The stroke projects out as a square, with the center at the end of the path.
*/
Square
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/paths/model/PatternItem.kt
================================================
package ovh.plrapps.mapcompose.ui.paths.model
import androidx.compose.ui.unit.Dp
sealed interface PatternItem {
data class Dash(val length: Dp): PatternItem
data object Dot: PatternItem
data class Gap(val length: Dp): PatternItem
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/state/MapState.kt
================================================
package ovh.plrapps.mapcompose.ui.state
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import ovh.plrapps.mapcompose.core.GestureConfiguration
import ovh.plrapps.mapcompose.core.Viewport
import ovh.plrapps.mapcompose.core.VisibleTilesResolver
import ovh.plrapps.mapcompose.core.throttle
import ovh.plrapps.mapcompose.ui.gestures.model.HitType
import ovh.plrapps.mapcompose.ui.layout.Fit
import ovh.plrapps.mapcompose.ui.layout.MinimumScaleMode
import ovh.plrapps.mapcompose.ui.state.markers.MarkerRenderState
import ovh.plrapps.mapcompose.ui.state.markers.MarkerState
import ovh.plrapps.mapcompose.utils.AngleDegree
import ovh.plrapps.mapcompose.utils.toRad
/**
* The state of the map. All public APIs are extensions functions or extension properties of this
* class.
*
* @param levelCount The number of levels in the pyramid.
* @param fullWidth The width in pixels of the map at scale 1f.
* @param fullHeight The height in pixels of the map at scale 1f.
* @param tileSize The size in pixels of tiles, which are expected to be squared. Defaults to 256.
* @param workerCount The thread count used to fetch tiles. Defaults to the number of cores minus
* one, which works well for tiles in the file system or in a local database. However, that number
* should be increased to 16 or more for remote tiles (HTTP requests).
* @param initialValuesBuilder A builder for [InitialValues] which are applied during [MapState]
* initialization. Note that the provided lambda should not start any coroutines.
*/
class MapState(
levelCount: Int,
fullWidth: Int,
fullHeight: Int,
tileSize: Int = 256,
workerCount: Int = Runtime.getRuntime().availableProcessors() - 1,
initialValuesBuilder: InitialValues.() -> Unit = {}
) : ZoomPanRotateStateListener {
private val initialValues = InitialValues().apply(initialValuesBuilder)
internal val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
internal val zoomPanRotateState = ZoomPanRotateState(
fullWidth = fullWidth,
fullHeight = fullHeight,
stateChangeListener = this,
minimumScaleMode = initialValues.minimumScaleMode,
maxScale = initialValues.maxScale,
scale = initialValues.scale,
rotation = initialValues.rotation,
gestureConfiguration = initialValues.gestureConfiguration,
infiniteScrollX = initialValues.infiniteScrollX
)
internal val markerRenderState = MarkerRenderState()
internal val markerState = MarkerState(scope, markerRenderState)
internal val pathState = PathState(fullWidth, fullHeight)
internal val visibleTilesResolver =
VisibleTilesResolver(
levelCount = levelCount,
fullWidth = fullWidth,
fullHeight = fullHeight,
tileSize = tileSize,
magnifyingFactor = initialValues.magnifyingFactor,
infiniteScrollX = initialValues.infiniteScrollX
) {
zoomPanRotateState.scale
}
internal val tileCanvasState = TileCanvasState(
scope,
tileSize,
visibleTilesResolver,
workerCount,
initialValues.highFidelityColors
)
private val throttledTask = scope.throttle(wait = 18) {
renderVisibleTiles()
}
private val viewport = Viewport()
internal var preloadingPadding: Int = initialValues.preloadingPadding
internal val tileSize by mutableIntStateOf(tileSize)
internal var stateChangeListener: (MapState.() -> Unit)? = null
internal var touchDownCb: (() -> Unit)? = null
internal var tapCb: LayoutTapCb? = null
internal var longPressCb: LayoutTapCb? = null
internal var mapBackground by mutableStateOf(Color.Transparent)
internal var isFilteringBitmap: () -> Boolean by mutableStateOf(
{ initialValues.isFilteringBitmap(this) }
)
private var consumeLateInitialValues: () -> Unit = {
consumeLateInitialValues = {}
applyLateInitialValues(initialValues)
}
/**
* Cancels all internal tasks.
* After this call, this [MapState] is unusable.
*/
@Suppress("unused")
fun shutdown() {
scope.cancel()
tileCanvasState.shutdown()
pathState.removeAllPaths()
markerState.removeAllMarkers()
}
override fun onStateChanged() {
consumeLateInitialValues()
renderVisibleTilesThrottled()
stateChangeListener?.invoke(this)
}
override fun onTouchDown() {
touchDownCb?.invoke()
}
override fun onPress() {
markerRenderState.removeAllAutoDismissCallouts()
}
override fun onLongPress(x: Double, y: Double) {
longPressCb?.invoke(x, y)
}
override fun onTap(x: Double, y: Double) {
tapCb?.invoke(x, y)
}
override fun detectsTap(): Boolean = tapCb != null
override fun detectsLongPress(): Boolean = longPressCb != null
override fun interceptsTap(x: Double, y: Double, xPx: Int, yPx: Int): Boolean {
val markerHandled = markerState.onHit(xPx, yPx, hitType = HitType.Click)
val pathHandled = if (!markerHandled) {
pathState.onHit(x, y, zoomPanRotateState.scale, hitType = HitType.Click)
} else false
return markerHandled || pathHandled
}
override fun interceptsLongPress(x: Double, y: Double, xPx: Int, yPx: Int): Boolean {
val markerHandled = markerState.onHit(xPx, yPx, hitType = HitType.LongPress)
val pathHandled = if (!markerHandled) {
pathState.onHit(x, y, zoomPanRotateState.scale, hitType = HitType.LongPress)
} else false
return markerHandled || pathHandled
}
internal fun renderVisibleTilesThrottled() {
throttledTask.trySend(Unit)
}
private suspend fun renderVisibleTiles() {
val viewport = updateViewport()
tileCanvasState.setViewport(viewport)
}
private fun updateViewport(): Viewport {
val padding = preloadingPadding
return viewport.apply {
left = zoomPanRotateState.scrollX.toInt() - padding
top = zoomPanRotateState.scrollY.toInt() - padding
right = left + zoomPanRotateState.layoutSize.width + padding * 2
bottom = top + zoomPanRotateState.layoutSize.height + padding * 2
angleRad = zoomPanRotateState.rotation.toRad()
}
}
/**
* Apply "late" initial values - e.g, those which depend on the layout size.
* For the moment, the scroll is the only one.
*/
private fun applyLateInitialValues(initialValues: InitialValues) {
with(zoomPanRotateState) {
val offsetX = initialValues.screenOffset.x * layoutSize.width
val offsetY = initialValues.screenOffset.y * layoutSize.height
val destScrollX = initialValues.x * fullWidth * scale + offsetX
val destScrollY = initialValues.y * fullHeight * scale + offsetY
setScroll(destScrollX, destScrollY)
}
}
}
/**
* Builder for initial values.
* Changes made after the `MapState` instance creation take precedence over initial values.
* In the following example, the init scale will be 4.0 since the max scale is later set to 4.0.
*
* ```
* MapState(4, 4096, 4096,
* initialValues = InitialValues().scale(8.0)
* ).apply {
* addLayer(tileStreamProvider)
* maxScale = 4.0
* }
* ```
*/
@Suppress("unused")
class InitialValues internal constructor() {
internal var x = 0.5
internal var y = 0.5
internal var screenOffset: Offset = Offset(-0.5f, -0.5f)
internal var scale: Double = 1.0
internal var minimumScaleMode: MinimumScaleMode = Fit
internal var maxScale: Double = 2.0
internal var rotation: AngleDegree = 0f
internal var magnifyingFactor = 0
internal var infiniteScrollX = false
internal var highFidelityColors: Boolean = true
internal var preloadingPadding: Int = 0
internal var isFilteringBitmap: (MapState) -> Boolean = { true }
internal var gestureConfiguration: GestureConfiguration = GestureConfiguration()
/**
* Init the scroll position. Defaults to centering on the provided scroll destination.
*
* @param x The normalized X position on the map, in range [0..1]
* @param y The normalized Y position on the map, in range [0..1]
* @param screenOffset Offset of the screen relatively to its dimension. Default is
* Offset(-0.5f, -0.5f), so moving the screen by half the width left and by half the height top,
* effectively centering on the scroll destination.
*/
fun scroll(x: Double, y: Double, screenOffset: Offset = Offset(-0.5f, -0.5f)) = apply {
this.screenOffset = screenOffset
this.x = x
this.y = y
}
/**
* Set the initial scale. Defaults to 1.0.
*/
fun scale(scale: Double) = apply {
this.scale = scale
}
/**
* Set the [MinimumScaleMode]. Defaults to [Fit].
*/
fun minimumScaleMode(minimumScaleMode: MinimumScaleMode) = apply {
this.minimumScaleMode = minimumScaleMode
}
/**
* Set the maximum allowed scale. Defaults to 2.0.
*/
fun maxScale(maxScale: Double) = apply {
this.maxScale = maxScale
}
/**
* Set the initial rotation. Defaults to 0° (no rotation).
*/
fun rotation(rotation: AngleDegree) = apply {
this.rotation = rotation
}
/**
* Alters the level at which tiles are picked for a given scale. By default, the level
* immediately higher (in index) is picked, to avoid sub-sampling. This corresponds to a
* [magnifyingFactor] of 0. The value 1 will result in picking the current level at a given
* scale, which will be at a relative scale between 1.0 and 2.0
*/
fun magnifyingFactor(magnifyingFactor: Int) = apply {
this.magnifyingFactor = magnifyingFactor.coerceAtLeast(0)
}
/**
* On API level 29 and above, HARDWARE bitmaps are used and this api is irrelevant.
* On API 28 and below, by default bitmaps are loaded using ARGB_8888, which is best suited for
* most usages.
* However, if you're only loading images without alpha channel and high fidelity color isn't
* a requirement, RGB_565 can be used instead for less memory usage (by setting this to false).
* Beware, however, that some types of images can't be loaded using RGB_565 (such as PNGs with
* alpha channel). Unless you know what you're doing, let this parameter be true.
*/
fun highFidelityColors(enabled: Boolean) = apply {
this.highFidelityColors = enabled
}
/**
* By default, only visible tiles are loaded. By adding a preloadingPadding additional tiles
* will be loaded, which can be used to produce a seamless tile loading effect.
*
* @param padding in pixels
*/
fun preloadingPadding(padding: Int) = apply {
this.preloadingPadding = padding.coerceAtLeast(0)
}
/**
* Controls whether Bitmap filtering is enabled when drawing tiles. This is enabled by default.
* Disabling it is useful to achieve nearest-neighbor scaling, for cases when the art style of
* the displayed image benefits from it.
* @see [android.graphics.Paint.setFilterBitmap]
*/
fun bitmapFilteringEnabled(enabled: Boolean) = apply {
bitmapFilteringEnabled { enabled }
}
/**
* A version of [bitmapFilteringEnabled] which allows for dynamic control of bitmap filtering
* depending on the current [MapState].
*/
fun bitmapFilteringEnabled(predicate: (state: MapState) -> Boolean) = apply {
isFilteringBitmap = predicate
}
/**
* Customize gestures.
*/
fun configureGestures(gestureConfigurationBlock: GestureConfiguration.() -> Unit) {
this.gestureConfiguration.gestureConfigurationBlock()
}
/**
* Enable infinite scroll on x-axis. When enabled, the scroll offset ratio in x dimension has
* no effect.
*/
fun infiniteScrollX(enabled: Boolean) {
infiniteScrollX = enabled
}
}
internal typealias LayoutTapCb = (x: Double, y: Double) -> Unit
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/state/PathState.kt
================================================
package ovh.plrapps.mapcompose.ui.state
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.MutableStateFlow
import ovh.plrapps.mapcompose.ui.gestures.model.HitType
import ovh.plrapps.mapcompose.ui.paths.PathData
import ovh.plrapps.mapcompose.ui.paths.model.Cap
import ovh.plrapps.mapcompose.ui.paths.model.PatternItem
import ovh.plrapps.mapcompose.utils.Point
import ovh.plrapps.mapcompose.utils.dpToPx
import ovh.plrapps.mapcompose.utils.getDistance
import ovh.plrapps.mapcompose.utils.getDistanceFromBox
import ovh.plrapps.mapcompose.utils.getNearestPoint
import ovh.plrapps.mapcompose.utils.isInsideBox
internal class PathState(
val fullWidth: Int,
val fullHeight: Int
) {
val pathState = mutableStateMapOf()
var pathClickCb: PathClickCb? = null
var pathHitTraversalCb: PathHitTraversalCb? = null
var pathLongPressCb: PathClickCb? = null
private val hasClickable = derivedStateOf {
pathState.values.any {
it.isClickable
}
}
fun addPath(
id: String,
path: PathData,
width: Dp?,
color: Color?,
fillColor: Color?,
offset: Int?,
count: Int?,
cap: Cap,
simplify: Float?,
clickable: Boolean,
zIndex: Float,
pattern: List?
) {
if (hasPath(id)) return
pathState[id] = DrawablePathState(id, path, width, color,fillColor, offset, count, cap, simplify, clickable, zIndex, pattern)
}
fun removePath(id: String): Boolean {
return pathState.remove(id) != null
}
fun removeAllPaths() {
pathState.clear()
}
fun removePaths(predicate: (String) -> Boolean) {
val iter = pathState.iterator()
for ((id, _) in iter) {
if (predicate(id)) iter.remove()
}
}
fun updatePath(
id: String,
pathData: PathData? = null,
visible: Boolean? = null,
width: Dp? = null,
color: Color? = null,
fillColor: Color? = null,
offset: Int? = null,
count: Int? = null,
cap: Cap? = null,
simplify: Float? = null,
clickable: Boolean? = null,
zIndex: Float? = null,
pattern: List? = null
) {
pathState[id]?.apply {
val path = this
pathData?.also {
path.pathData = it
resetOffsetAndCount()
}
visible?.also { path.visible = it }
width?.also { path.width = it }
color?.also { path.color = it }
fillColor?.also { path.fillColor = it }
cap?.also { path.cap = it }
simplify?.also { path.simplify = it.coerceAtLeast(0f) }
if (offset != null || count != null) {
offsetAndCount = coerceOffsetAndCount(offset, count)
}
clickable?.also { path.isClickable = it }
zIndex?.also { path.zIndex = it }
pattern?.also { path.pattern = it }
}
}
fun hasPath(id: String): Boolean {
return pathState.keys.contains(id)
}
/**
* [x], [y] are the relative coordinates of the tap.
*/
fun onHit(x: Double, y: Double, scale: Double, hitType: HitType): Boolean {
if (!hasClickable.value) return false
/* Compute pixel coordinates, at scale 1 because path coordinates (see below) are at scale 1 */
val xPx = x * fullWidth
val yPx = y * fullHeight
val radius = dpToPx(12f)
val threshold = radius / scale
val traversalClickIds = mutableListOf()
var traversalClickPosition: Point? = null
val candidates = pathState.entries
.filter { it.value.isClickable }
/* Sort by descending draw order and not just z-index, because z-index is an application
* concept and for two paths with the same z-index, the draw order is undetermined.
* The draw order is a low level information, unknown to the application. */
.sortedByDescending { it.value.drawOrder.value }
for ((id, pathState) in candidates) {
val bb = pathState.pathData.boundingBox
val (topLeft, bottomRight) = bb
val (xMin, yMin) = topLeft
val (xMax, yMax) = bottomRight
/* Don't compute the nearest point for a point outside of the bounding box and with a
* distance to the bounding box greater than the threshold */
if (!isInsideBox(xPx, yPx, xMin, xMax, yMin, yMax) && getDistanceFromBox(xPx, yPx, xMin, xMax, yMin, yMax) > threshold) {
continue
}
var d = Double.MAX_VALUE
var nearestP1: Point? = null
var nearestP2: Point? = null
val points = pathState.currentDecimatedPath.value ?: pathState.pathData.data
for (i in points.indices) {
if (i + 1 == points.size) break
val p1 = points[i]
val p2 = points[i + 1]
val dist = getDistance(xPx, yPx, p1.x, p1.y, p2.x, p2.y)
if (dist < threshold && dist < d) {
d = dist
nearestP1 = p1
nearestP2 = p2
}
}
if (nearestP1 != null && nearestP2 != null) {
val nearest =
getNearestPoint(xPx, yPx, nearestP1.x, nearestP1.y, nearestP2.x, nearestP2.y)
val xOnPath = nearest.x / fullWidth
val yOnPath = nearest.y / fullHeight
if (pathHitTraversalCb == null) {
when (hitType) {
HitType.Click -> pathClickCb?.invoke(id, xOnPath, yOnPath)
HitType.LongPress -> pathLongPressCb?.invoke(id, xOnPath, yOnPath)
}
return true
} else {
traversalClickIds.add(id)
if (traversalClickPosition == null) {
traversalClickPosition = Point(xOnPath, yOnPath)
}
}
}
}
return if (pathHitTraversalCb == null) {
false
} else {
if (traversalClickIds.isNotEmpty()) {
val pos = traversalClickPosition
if (pos != null) { // should always be true
pathHitTraversalCb?.invoke(traversalClickIds, pos.x, pos.y, hitType)
}
true
} else false
}
}
/**
* The scale doesn't matter as all computations are done at scale 1.
*/
fun isPathWithinRange(id: String, rangePx: Int, x: Double, y: Double): Boolean {
val drawablePathState = pathState[id] ?: return false
/* Compute pixel coordinates, at scale 1 because path coordinates (see below) are at scale 1 */
val xPx = x * fullWidth
val yPx = y * fullHeight
for (i in 0 until drawablePathState.pathData.data.size) {
if (i + 1 == drawablePathState.pathData.data.size) break
val p1 = drawablePathState.pathData.data[i]
val p2 = drawablePathState.pathData.data[i + 1]
val dist = getDistance(xPx, yPx, p1.x, p1.y, p2.x, p2.y)
if (dist < rangePx) {
return true
}
}
return false
}
}
internal class DrawablePathState(
val id: String,
pathData: PathData,
width: Dp?,
color: Color?,
fillColor: Color?,
offset: Int?,
count: Int?,
cap: Cap,
simplify: Float?,
clickable: Boolean,
zIndex: Float,
pattern: List?
) {
/* Using a StateFlow mainly for its thread-safety (value is written off ui thread, and we do
* not want/need to switch to the main thread while updating this value) */
val currentDecimatedPath = MutableStateFlow?>(null)
var pathData by mutableStateOf(pathData)
var visible by mutableStateOf(true)
var width: Dp by mutableStateOf(width ?: 4.dp)
var color: Color by mutableStateOf(color ?: Color(0xFF448AFF))
var fillColor: Color? by mutableStateOf(fillColor)
var cap: Cap by mutableStateOf(cap)
var isClickable: Boolean by mutableStateOf(clickable)
var zIndex: Float by mutableFloatStateOf(zIndex)
var pattern: List? by mutableStateOf(pattern)
/**
* The "count" is the number of values in [pathData] to process, after skipping "offset" of them.
*/
var offsetAndCount: IntOffset by mutableStateOf(initializeOffsetAndCount(offset, count))
var simplify: Float by mutableFloatStateOf(simplify?.coerceAtLeast(0f) ?: 1f)
val drawOrder = MutableStateFlow(0)
fun resetOffsetAndCount() {
offsetAndCount = IntOffset(0, pathData.data.size)
}
private fun initializeOffsetAndCount(offset: Int?, cnt: Int?): IntOffset {
val ofst = offset?.coerceIn(0, pathData.data.size) ?: 0
val count = (cnt ?: pathData.data.size).coerceIn(
0, (pathData.data.size - ofst)
)
return IntOffset(ofst, count)
}
/**
* Ensure that "count" + "offset" shouldn't exceed the path length.
*/
fun coerceOffsetAndCount(offset: Int?, cnt: Int?): IntOffset {
val ofst = (offset ?: offsetAndCount.x).coerceIn(0, pathData.data.size)
val count = (cnt ?: offsetAndCount.y).coerceIn(0, (pathData.data.size - ofst))
return IntOffset(ofst, count)
}
override fun hashCode(): Int {
var hash = id.hashCode()
hash += 31 * pathData.data.size
return hash
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DrawablePathState
if (id != other.id) return false
if (pathData.data.size != other.pathData.data.size) return false
return true
}
}
internal typealias PathClickCb = (id: String, x: Double, y: Double) -> Unit
internal typealias PathHitTraversalCb = (ids: List, x: Double, y: Double, hitType: HitType) -> Unit
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/state/TileCanvasState.kt
================================================
package ovh.plrapps.mapcompose.ui.state
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.*
import ovh.plrapps.mapcompose.core.*
import java.util.concurrent.Executors
import kotlin.math.pow
import kotlin.time.TimeSource
/**
* This class contains all the logic related to [Tile] management.
* It defers [Tile] loading to the [TileCollector].
* All internal data manipulation are thread-confined to a single background thread. This is
* guarantied by the [scope] and its custom dispatcher.
* Ultimately, it exposes the list of tiles to render ([tilesToRender]) which is backed by a
* [MutableState]. A composable using [tilesToRender] will be automatically recomposed when this
* list changes.
*
* @author P.Laurence on 04/06/2019
*/
internal class TileCanvasState(
parentScope: CoroutineScope, tileSize: Int,
private val visibleTilesResolver: VisibleTilesResolver,
workerCount: Int, highFidelityColors: Boolean
) {
/* This view-model uses a background thread for its computations */
private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val scope = CoroutineScope(
parentScope.coroutineContext + singleThreadDispatcher
)
internal var tilesToRender: List by mutableStateOf(listOf())
private var tilesCollectedBySpace: Map = mapOf()
private val _layerFlow = MutableStateFlow>(listOf())
internal val layerFlow = _layerFlow.asStateFlow()
private val visibleTileLocationsChannel = Channel(capacity = Channel.RENDEZVOUS)
private val tilesOutput = Channel(capacity = Channel.RENDEZVOUS)
private val visibleStateFlow = MutableStateFlow(null)
internal var alphaTick = 0.07f
set(value) {
field = value.coerceIn(0.01f, 1f)
}
internal var colorFilterProvider: ColorFilterProvider? by mutableStateOf(null)
private val recycleChannel = Channel(Channel.UNLIMITED)
/**
* So long as this debounced channel is offered a message, the lambda isn't called.
*/
private val idleDebounced = scope.debounce(400) {
visibleStateFlow.value?.also { (visibleTiles, layerIds, opacities) ->
evictTiles(visibleTiles, layerIds, opacities, aggressiveAttempt = true)
renderTiles(visibleTiles, layerIds, opacities)
}
}
private val renderTask = scope.throttle(wait = 34) {
/* Evict, then render */
val (lastVisible, ids, opacities) = visibleStateFlow.value ?: return@throttle
evictTiles(lastVisible, ids, opacities)
renderTiles(lastVisible, ids, opacities)
}
private fun renderTiles(
visibleTiles: VisibleTiles,
layerIds: List,
opacities: List
) {
/* Right before sending tiles to the view, reorder them so that tiles from current level are
* above others. */
val tilesToRenderCopy = tilesCollected.sortedBy {
/* As a side effect of sorting tiles, also set tile phases */
if (visibleTiles.visibleWindow is VisibleWindow.InfiniteScrollX) {
setTilePhases(it, visibleTiles.visibleWindow, visibleTiles.level, visibleTiles.visibleWindow.timeMark)
}
val priority =
if (it.zoom == visibleTiles.level && it.subSample == visibleTiles.subSample) 100 else 0
priority + if (layerIds == it.layerIds && opacities == it.opacities) 1 else 0
}
tilesToRender = tilesToRenderCopy
}
private val tilesCollected = mutableSetOf()
private val tileCollector: TileCollector
init {
/* Collect visible tiles and send specs to the TileCollector */
scope.launch {
collectNewTiles()
}
/* Launch the TileCollector */
tileCollector = TileCollector(
workerCount = workerCount.coerceAtLeast(1),
optimizeForLowEndDevices = !highFidelityColors,
tileSize = tileSize
)
scope.launch {
_layerFlow.collectLatest { layers ->
tileCollector.collectTiles(
tileSpecs = visibleTileLocationsChannel,
tilesOutput = tilesOutput,
layers = layers
)
}
}
/* Launch a coroutine to consume the produced tiles */
scope.launch {
consumeTiles(tilesOutput)
}
/* This is very important to null a tile's bitmap on the main thread because this ensures
* that on the next composition the bitmap won't be accessed.
* In the future, if the Compose framework does multi-threaded rendering, another technique
* will have to be used. Or, consider not using Bitmap.recycle() at all since it seems
* not necessary for hardware bitmaps. */
scope.launch(Dispatchers.Main) {
for (t in recycleChannel) {
val b = t.bitmap
t.bitmap = null
b?.recycle()
}
}
}
fun setLayers(layers: List) {
_layerFlow.value = layers
}
/**
* Forgets visible state and previously collected tiles.
* To clear the canvas, call [forgetTiles], then [renderThrottled].
*/
suspend fun forgetTiles() {
scope.launch {
visibleStateFlow.value = null
tilesCollected.clear()
}.join()
}
fun shutdown() {
singleThreadDispatcher.close()
tileCollector.shutdownNow()
}
suspend fun setViewport(viewport: Viewport) {
/* Thread-confine the tileResolver to the main thread */
val visibleTiles = withContext(Dispatchers.Main) {
visibleTilesResolver.getVisibleTiles(viewport)
}
withContext(scope.coroutineContext) {
setVisibleTiles(visibleTiles)
}
}
private fun setVisibleTiles(visibleTiles: VisibleTiles) {
/* Feed the tile processing machinery */
val layerIds = _layerFlow.value.map { it.id }
val opacities = _layerFlow.value.map { it.alpha }
val visibleTilesForLayers = VisibleState(visibleTiles, layerIds, opacities)
visibleStateFlow.value = visibleTilesForLayers
renderThrottled()
}
/**
* Consumes incoming visible tiles from [visibleStateFlow] and sends [TileSpec] instances to the
* [TileCollector].
*
* Leverage built-in back pressure, as this function will suspend when the tile collector is busy
* to the point it can't handshake the [visibleTileLocationsChannel] channel.
*
* Using [Flow.collectLatest], we cancel any ongoing previous tile list processing. It's
* particularly useful when the [TileCollector] is too slow, so when a new [VisibleTiles] element
* is received from [visibleStateFlow], no new [TileSpec] elements from the previous [VisibleTiles]
* element are sent to the [TileCollector]. When the [TileCollector] is ready to resume processing,
* the latest [VisibleTiles] element is processed right away.
*/
private suspend fun collectNewTiles() {
visibleStateFlow.collectLatest { visibleState ->
if (visibleState != null) {
when (visibleState.visibleTiles.visibleWindow) {
is VisibleWindow.BoundsConstrained -> {
sendSpecsForTileMatrix(
visibleState,
visibleState.visibleTiles.visibleWindow.tileMatrix
)
}
is VisibleWindow.InfiniteScrollX -> {
sendSpecsForTileMatrix(
visibleState,
visibleState.visibleTiles.visibleWindow.tileMatrix
)
val leftMatrix = visibleState.visibleTiles.visibleWindow.leftOverflow?.tileMatrix
if (leftMatrix != null) {
sendSpecsForTileMatrix(
visibleState,
leftMatrix
)
}
val rightMatrix = visibleState.visibleTiles.visibleWindow.rightOverflow?.tileMatrix
if (rightMatrix != null) {
sendSpecsForTileMatrix(
visibleState,
rightMatrix
)
}
}
}
}
}
}
private suspend fun sendSpecsForTileMatrix(
visibleState: VisibleState,
tileMatrix: TileMatrix
) {
val visibleTiles = visibleState.visibleTiles
for (e in tileMatrix) {
val row = e.key
val colRange = e.value
for (col in colRange) {
val tile = Tile(
zoom = visibleTiles.level,
row = row,
col = col,
subSample = visibleTiles.subSample,
layerIds = visibleState.layerIds,
opacities = visibleState.opacities
)
val alreadyProcessed = tilesCollected.contains(tile)
/* Only emit specs which haven't already been processed by the collector
* Doing this now results in less object allocations than filtering the flow
* afterwards */
if (!alreadyProcessed) {
visibleTileLocationsChannel.send(
TileSpec(
visibleTiles.level,
row,
col,
visibleTiles.subSample
)
)
}
}
}
}
/**
* For each [Tile] received, add it to the list of collected tiles if it's visible. Otherwise,
* recycle the tile.
*/
private suspend fun consumeTiles(tileChannel: ReceiveChannel) {
for (tile in tileChannel) {
val (lastVisible, layerIds, opacities) = visibleStateFlow.value ?: continue
if (
lastVisible.contains(tile)
&& !tilesCollected.contains(tile)
&& tile.layerIds == layerIds
&& tile.opacities == opacities
) {
val tileWithSameSpace = tilesCollectedBySpace[tile.spaceKey()]
if (tileWithSameSpace != null && (tileWithSameSpace.layerIds != tile.layerIds || tileWithSameSpace.opacities != tile.opacities)) {
tile.overlaps = tileWithSameSpace
/* A tile already occupies the same space, so we don't need any fade-in */
tile.alpha = 1f
} else {
tile.prepare()
}
tilesCollected.add(tile)
renderThrottled()
} else {
tile.recycle()
}
fullEvictionDebounced()
}
}
private fun fullEvictionDebounced() {
idleDebounced.trySend(Unit)
}
/**
* The the alpha needs to be set to [alphaTick], to produce a fade-in effect. If [alphaTick] is
* 1f, the alpha won't be updated and there won't be any fade-in effect.
*/
private fun Tile.prepare() {
alpha = alphaTick
}
private fun VisibleTiles.contains(tile: Tile): Boolean {
if (level != tile.zoom) return false
return when (visibleWindow) {
is VisibleWindow.BoundsConstrained -> {
val colRange = visibleWindow.tileMatrix[tile.row] ?: return false
subSample == tile.subSample && tile.col in colRange
}
is VisibleWindow.InfiniteScrollX -> {
if (subSample != tile.subSample) return false
visibleWindow.tileMatrix[tile.row]?.let { range ->
tile.col in range
} == true ||
visibleWindow.leftOverflow?.tileMatrix?.get(tile.row)?.let { range ->
tile.col in range
} == true ||
visibleWindow.rightOverflow?.tileMatrix?.get(tile.row)?.let { range ->
tile.col in range
} == true
}
}
}
private fun VisibleTiles.intersects(tile: Tile): Boolean {
fun checkIntersection(tileMatrix: TileMatrix, tile: Tile): Boolean {
return if (level == tile.zoom) {
val colRange = tileMatrix[tile.row] ?: return false
tile.col in colRange
} else {
val curMinRow = tileMatrix.keys.minOrNull() ?: return false
val curMaxRow = tileMatrix.keys.maxOrNull() ?: return false
val curMinCol = tileMatrix.entries.firstOrNull()?.value?.first ?: return false
val curMaxCol = tileMatrix.entries.firstOrNull()?.value?.last ?: return false
if (tile.zoom > level) { // User is zooming out
val dLevel = tile.zoom - level
val minRowAtLvl = curMinRow.minAtGreaterLevel(dLevel)
val maxRowAtLvl = curMaxRow.maxAtGreaterLevel(dLevel)
val minColAtLvl = curMinCol.minAtGreaterLevel(dLevel)
val maxColAtLvl = curMaxCol.maxAtGreaterLevel(dLevel)
return tile.row in minRowAtLvl..maxRowAtLvl && tile.col in minColAtLvl..maxColAtLvl
} else { // User is zooming in
val dLevel = level - tile.zoom
val minRowAtLvl = tile.row.minAtGreaterLevel(dLevel)
val maxRowAtLvl = tile.row.maxAtGreaterLevel(dLevel)
val minColAtLvl = tile.col.minAtGreaterLevel(dLevel)
val maxColAtLvl = tile.col.maxAtGreaterLevel(dLevel)
return curMinCol <= maxColAtLvl && minColAtLvl <= curMaxCol && curMinRow <= maxRowAtLvl &&
minRowAtLvl <= curMaxRow
}
}
}
return when (visibleWindow) {
is VisibleWindow.BoundsConstrained -> checkIntersection(visibleWindow.tileMatrix, tile)
is VisibleWindow.InfiniteScrollX -> {
val mainIntersect = checkIntersection(visibleWindow.tileMatrix, tile)
mainIntersect || (visibleWindow.leftOverflow != null && checkIntersection(
visibleWindow.leftOverflow.tileMatrix,
tile
)) || (visibleWindow.rightOverflow != null && checkIntersection(
visibleWindow.rightOverflow.tileMatrix,
tile
))
}
}
}
private fun updateTileCollectedBySpace() {
tilesCollectedBySpace = tilesCollected.associateBy {
it.spaceKey()
}
}
/**
* Each time we get a new [VisibleTiles], remove all [Tile] from [tilesCollected] which aren't
* visible or that aren't needed anymore and put their bitmap into the pool.
*/
private fun evictTiles(
visibleTiles: VisibleTiles,
layerIds: List,
opacities: List,
aggressiveAttempt: Boolean = false
) {
val currentLevel = visibleTiles.level
val currentSubSample = visibleTiles.subSample
/* Always perform partial eviction */
partialEviction(visibleTiles, layerIds, opacities)
/* Only perform aggressive eviction when tile collector is idle */
if (aggressiveAttempt && tileCollector.isIdle) {
aggressiveEviction(currentLevel, currentSubSample, layerIds, opacities)
}
/* Now that tileCollected is cleaned up, update an internal data structure */
updateTileCollectedBySpace()
}
/**
* Evict:
* * tiles of levels different than the current one, that aren't visible,
* * tiles that aren't visible at current level, and tiles from current level which aren't made
* of current layers
*/
private fun partialEviction(
visibleTiles: VisibleTiles,
layerIds: List,
opacities: List
) {
val currentLevel = visibleTiles.level
val currentSubSample = visibleTiles.subSample
val addedSet = mutableSetOf()
val iterator = tilesCollected.iterator()
while (iterator.hasNext()) {
val tile = iterator.next()
if (layerIds == tile.layerIds && opacities == tile.opacities) {
val spaceHash = tile.spaceKey()
addedSet.add(spaceHash)
}
if (layerIds.isEmpty() || tile.zoom != currentLevel && !visibleTiles.intersects(tile)) {
iterator.remove()
tile.recycle()
continue
}
if (
tile.zoom == currentLevel
&& tile.subSample == currentSubSample
&& (!visibleTiles.contains(tile) || tile.markedForSweep)
) {
iterator.remove()
tile.recycle()
}
}
/* Now that we know all tiles with the latest layerIds and opacities, forget the other
* tiles which occupy the same space. Don't recycle the associated bitmaps because some of
* the latest tiles haven't been drawn yet. So we rely on garbage collection for these
* bitmaps. */
val secondPass = tilesCollected.iterator()
while (secondPass.hasNext()) {
val tile = secondPass.next()
if (layerIds != tile.layerIds || opacities != tile.opacities) {
val spaceHash = tile.spaceKey()
if (addedSet.contains(spaceHash)) {
secondPass.remove()
}
}
}
}
/**
* Removes tiles of other levels, even if they are visible (although they should be drawn beneath
* currently visible tiles).
* Only triggered after the [idleDebounced] fires.
*/
private fun aggressiveEviction(
currentLevel: Int,
currentSubSample: Int,
layerIds: List,
opacities: List
) {
val iterator = tilesCollected.iterator()
while (iterator.hasNext()) {
val tile = iterator.next()
/* Remove tiles at the same level but from other layers */
if (
tile.zoom == currentLevel
&& tile.subSample == currentSubSample
&& (tile.layerIds != layerIds || tile.opacities != opacities)
) {
iterator.remove()
tile.recycle()
}
/* Remove other tiles at different level and sub-sample */
if ((tile.zoom != currentLevel && tile.subSample == 0)
|| (tile.zoom == 0 && tile.subSample != currentSubSample)
) {
iterator.remove()
tile.recycle()
}
}
}
/**
* Post a new value to the observable. The view should update its UI.
*/
private fun renderThrottled() {
renderTask.trySend(Unit)
}
/**
* After a [Tile] is no longer visible, depending on the bitmap mutability:
* - If the Bitmap is mutable, put it into the pool for later use.
* - If the bitmap isn't mutable, we don't use bitmap pooling. That means the associated graphic
* memory can be reclaimed asap.
* The Compose framework draws tiles on the main thread and checks whether or not [Tile.bitmap]
* is null. So, prior to calling recycle() we set [Tile.bitmap] to null on the main thread. This
* is done inside the coroutine which consumes [recycleChannel].
*/
private fun Tile.recycle() {
val b = bitmap ?: return
if (!b.isMutable) {
recycleChannel.trySend(this)
}
alpha = 0f
}
private fun Int.minAtGreaterLevel(n: Int): Int {
return this * 2.0.pow(n).toInt()
}
private fun Int.maxAtGreaterLevel(n: Int): Int {
return (this + 1) * 2.0.pow(n).toInt() - 1
}
private fun setTilePhases(tile: Tile, visibleWindow: VisibleWindow.InfiniteScrollX, level: Int, timeMark: TimeSource.Monotonic.ValueTimeMark) {
if (tile.zoom != level) return
val left = visibleWindow.leftOverflow?.phase?.get(tile.col)
val right = visibleWindow.rightOverflow?.phase?.get(tile.col)
val inCenter = tile.col in (visibleWindow.tileMatrix[tile.row] ?: IntRange.EMPTY)
tile.phases = if (left != null || right != null) {
IntRange(
start = left ?: (if (inCenter) 0 else 1),
endInclusive = right ?: (if (inCenter) 0 else -1)
)
} else null
tile.timeMark = timeMark
}
private data class VisibleState(
val visibleTiles: VisibleTiles,
val layerIds: List,
val opacities: List
)
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/state/ZoomPanRotateState.kt
================================================
package ovh.plrapps.mapcompose.ui.state
import androidx.compose.animation.core.*
import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.*
import ovh.plrapps.mapcompose.core.GestureConfiguration
import ovh.plrapps.mapcompose.ui.layout.*
import ovh.plrapps.mapcompose.utils.*
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.math.*
import kotlin.time.TimeSource
internal class ZoomPanRotateState(
val fullWidth: Int,
val fullHeight: Int,
private val stateChangeListener: ZoomPanRotateStateListener,
minimumScaleMode: MinimumScaleMode,
maxScale: Double,
scale: Double,
rotation: AngleDegree,
gestureConfiguration: GestureConfiguration,
val infiniteScrollX: Boolean,
) : GestureListener, LayoutSizeChangeListener {
private var scope: CoroutineScope? = null
private var onLayoutContinuations = mutableListOf>()
/**
* Suspends until the view is laid out. To do that, we use the [scope] as flag.
*
* _Contract_:
* On layout change, [scope] and [layoutSize] are initialized, and queued continuations
* are resumed.
*/
internal suspend fun awaitLayout() {
if (scope != null) return
suspendCoroutine {
onLayoutContinuations.add(it)
}
}
internal var minimumScaleMode: MinimumScaleMode = minimumScaleMode
set(value) {
field = value
recalculateMinScale()
}
private val areGesturesEnabled by derivedStateOf { isRotationEnabled || isScrollingEnabled || isZoomingEnabled }
internal var isRotationEnabled by mutableStateOf(false)
internal var isScrollingEnabled by mutableStateOf(true)
internal var isZoomingEnabled by mutableStateOf(true)
internal var isFlingZoomEnabled by mutableStateOf(true)
/* Single source of truth. Don't mutate directly, use appropriate setScale(), setRotation(), etc. */
internal var scale by mutableDoubleStateOf(scale)
internal var rotation: AngleDegree by mutableFloatStateOf(rotation)
internal var scrollX by mutableDoubleStateOf(0.0)
internal var scrollY by mutableDoubleStateOf(0.0)
internal var pivotX: Double by mutableDoubleStateOf(0.0)
internal var pivotY: Double by mutableDoubleStateOf(0.0)
internal var centroidX: Double by mutableDoubleStateOf(0.0)
internal var centroidY: Double by mutableDoubleStateOf(0.0)
internal var layoutSize by mutableStateOf(IntSize.Zero)
internal var visibleAreaPadding = VisibleAreaPadding(0, 0, 0, 0)
internal var minScale by mutableDoubleStateOf(0.0) // should only be changed through MinimumScaleMode
var maxScale = maxScale
set(value) {
field = value
setScale(scale)
}
internal var shouldLoopScale by mutableStateOf(false)
internal var scrollOffsetRatio = Offset(0f, 0f)
set(value) {
if (value.x in 0f..1f && value.y in 0f..1f) {
field = value
/* Update the scroll to constrain it */
setScroll(
scrollX = scrollX,
scrollY = scrollY
)
} else throw IllegalArgumentException("The offset ratio should have values in 0f..1f range")
}
internal val rolloverX = mutableStateOf(null)
// For user gestures animations
private val userFloatAnimatable = Animatable(0f)
private val userAnimatable: Animatable =
Animatable(Offset.Zero, Offset.VectorConverter)
// For api-based animations
private val apiAnimatable = Animatable(0f)
private val doubleTapSpec =
TweenSpec(durationMillis = 300, easing = LinearOutSlowInEasing)
private val flingZoomSpec =
FloatExponentialDecaySpec(
frictionMultiplier = gestureConfiguration.flingZoomFriction
).generateDecayAnimationSpec()
@Suppress("unused")
fun setScale(scale: Double, notify: Boolean = true) {
this.scale = constrainScale(scale)
updateCentroid()
if (notify) notifyStateChanged()
}
@Suppress("unused")
fun setScroll(scrollX: Double, scrollY: Double) {
this.scrollX = constrainScrollX(scrollX)
this.scrollY = constrainScrollY(scrollY)
updateCentroid()
notifyStateChanged()
}
@Suppress("unused")
fun setRotation(angle: AngleDegree, notify: Boolean = true) {
this.rotation = angle.modulo()
updateCentroid()
if (notify) notifyStateChanged()
}
/**
* Scales the layout with animated scale, without maintaining scroll position.
*
* @param scale The final scale value the layout should animate to.
* @param animationSpec The [AnimationSpec] the animation should use.
*/
@Suppress("unused")
suspend fun smoothScaleTo(
scale: Double,
animationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow)
): Boolean {
return invokeAndCheckSuccess {
val currScale = this@ZoomPanRotateState.scale
if (currScale > 0) {
apiAnimatable.snapTo(0f)
apiAnimatable.animateTo(1f, animationSpec) {
setScale(lerp(currScale, scale, value.toDouble()))
}
}
}
}
suspend fun smoothRotateTo(
angle: AngleDegree,
animationSpec: AnimationSpec
): Boolean {
/* We don't have to stop scrolling animation while doing that */
return invokeAndCheckSuccess {
val currRotation = this@ZoomPanRotateState.rotation
var targetAngle = (angle % 360)
if (abs(targetAngle - currRotation) > 180) {
targetAngle += if (targetAngle > currRotation) -360 else 360
}
apiAnimatable.snapTo(0f)
apiAnimatable.animateTo(1f, animationSpec) {
setRotation(lerp(currRotation, targetAngle, value))
}
}
}
/**
* Animates the scroll to the destination value.
*
* @return `true` if the operation completed without being cancelled.
*/
suspend fun smoothScrollTo(
destScrollX: Double,
destScrollY: Double,
animationSpec: AnimationSpec
): Boolean {
val startScrollX = this.scrollX
val startScrollY = this.scrollY
return invokeAndCheckSuccess {
userAnimatable.stop()
apiAnimatable.snapTo(0f)
apiAnimatable.animateTo(1f, animationSpec) {
setScroll(
scrollX = lerp(startScrollX, destScrollX, value.toDouble()),
scrollY = lerp(startScrollY, destScrollY, value.toDouble())
)
}
}
}
/**
* Animates the scroll and the scale together with the supplied destination values.
*
* @param destScrollX Horizontal scroll of the destination point.
* @param destScrollY Vertical scroll of the destination point.
* @param destScale The final scale value the layout should animate to.
* @param animationSpec The [AnimationSpec] the animation should use.
*/
suspend fun smoothScrollScaleRotate(
destScrollX: Double,
destScrollY: Double,
destScale: Double,
animationSpec: AnimationSpec
): Boolean {
val startScrollX = this.scrollX
val startScrollY = this.scrollY
val startScale = this.scale
return invokeAndCheckSuccess {
userAnimatable.stop()
apiAnimatable.snapTo(0f)
apiAnimatable.animateTo(1f, animationSpec) {
setScale(lerp(startScale, destScale, value.toDouble()))
setScroll(
scrollX = lerp(startScrollX, destScrollX, value.toDouble()),
scrollY = lerp(startScrollY, destScrollY, value.toDouble())
)
}
}
}
/**
* Animates the scroll, the scale, and the rotation together with the supplied destination values.
*
* @param destScrollX Horizontal scroll of the destination point.
* @param destScrollY Vertical scroll of the destination point.
* @param destScale The final scale value the layout should animate to.
* @param destAngle The final angle in decimal degrees the layout should animate to.
* @param animationSpec The [AnimationSpec] the animation should use.
*/
suspend fun smoothScrollScaleRotate(
destScrollX: Double,
destScrollY: Double,
destScale: Double,
destAngle: AngleDegree,
animationSpec: AnimationSpec
): Boolean {
val startScrollX = this.scrollX
val startScrollY = this.scrollY
val startScale = this.scale
val currRotation = this@ZoomPanRotateState.rotation
var targetAngle = (destAngle % 360)
if (abs(targetAngle - currRotation) > 180) {
targetAngle += if (targetAngle > currRotation) -360 else 360
}
return invokeAndCheckSuccess {
userAnimatable.stop()
apiAnimatable.snapTo(0f)
apiAnimatable.animateTo(1f, animationSpec) {
setScale(lerp(startScale, destScale, value.toDouble()))
setScroll(
scrollX = lerp(startScrollX, destScrollX, value.toDouble()),
scrollY = lerp(startScrollY, destScrollY, value.toDouble())
)
setRotation(lerp(currRotation, targetAngle, value))
}
}
}
/**
* Animates the layout to the scale provided, while maintaining position determined by the
* the provided focal point.
*
* @param focusX The horizontal focal point to maintain, relative to the layout.
* @param focusY The vertical focal point to maintain, relative to the layout.
* @param destScale The final scale value the layout should animate to.
* @param animationSpec The [AnimationSpec] the animation should use.
*/
private suspend fun smoothScaleWithFocalPoint(
focusX: Float,
focusY: Float,
destScale: Double,
animationSpec: AnimationSpec
): Boolean {
val destScaleCst = constrainScale(destScale)
val startScale = scale
if (startScale == destScale) return true
val startScrollX = scrollX
val startScrollY = scrollY
val destScrollX = getScrollAtOffsetAndScale(startScrollX, focusX, destScaleCst / startScale)
val destScrollY = getScrollAtOffsetAndScale(startScrollY, focusY, destScaleCst / startScale)
return smoothScrollScaleRotate(destScrollX, destScrollY, destScale, animationSpec)
}
/**
* Invokes [block] in the scope of the composition and return whether the operation completed
* without being cancelled.
*/
internal suspend fun invokeAndCheckSuccess(block: suspend () -> Unit): Boolean {
var success = true
scope?.launch {
block()
}?.also {
it.invokeOnCompletion { t ->
if (t != null) success = false
}
}?.join()
return success
}
suspend fun stopAnimations() {
apiAnimatable.stop()
userAnimatable.stop()
userFloatAnimatable.stop()
}
override fun onScaleRatio(scaleRatio: Double, centroid: Offset) {
if (!isZoomingEnabled) return
val formerScale = scale
setScale(scale * scaleRatio)
/* Pinch and zoom magic */
val effectiveScaleRatio = scale / formerScale
val angleRad = -rotation.toRad()
val centroidRotated = rotateFocalPoint(centroid, angleRad)
setScroll(
scrollX = getScrollAtOffsetAndScale(scrollX, centroidRotated.x, effectiveScaleRatio),
scrollY = getScrollAtOffsetAndScale(scrollY, centroidRotated.y, effectiveScaleRatio)
)
}
private fun getScrollAtOffsetAndScale(scroll: Double, offSet: Float, scaleRatio: Double): Double {
return (scroll + offSet) * scaleRatio - offSet
}
/**
* Rotates a focal point around the center of the layout.
*/
private fun rotateFocalPoint(point: Offset, angleRad: AngleRad): Offset {
val x = if (angleRad == 0f) point.x else {
layoutSize.height / 2 * sin(angleRad) + layoutSize.width / 2 * (1 - cos(angleRad)) +
point.x * cos(angleRad) - point.y * sin(angleRad)
}
val y = if (angleRad == 0f) point.y else {
layoutSize.height / 2 * (1 - cos(angleRad)) - layoutSize.width / 2 * sin(angleRad) +
point.x * sin(angleRad) + point.y * cos(angleRad)
}
return Offset(x, y)
}
override fun onRotationDelta(rotationDelta: Float) {
if (!isRotationEnabled) return
setRotation(rotation + rotationDelta)
}
override fun onScrollDelta(scrollDelta: Offset) {
if (!isScrollingEnabled) return
var scrollX = scrollX
var scrollY = scrollY
val rotRad = -rotation.toRad()
scrollX -= if (rotRad == 0f) scrollDelta.x else {
scrollDelta.x * cos(rotRad) - scrollDelta.y * sin(rotRad)
}
scrollY -= if (rotRad == 0f) scrollDelta.y else {
scrollDelta.x * sin(rotRad) + scrollDelta.y * cos(rotRad)
}
setScroll(scrollX, scrollY)
}
override fun onFling(flingSpec: DecayAnimationSpec, velocity: Velocity) {
if (!isScrollingEnabled) return
val rotRad = -rotation.toRad()
val velocityX = if (rotRad == 0f) velocity.x else {
velocity.x * cos(rotRad) - velocity.y * sin(rotRad)
}
val velocityY = if (rotRad == 0f) velocity.y else {
velocity.x * sin(rotRad) + velocity.y * cos(rotRad)
}
scope?.launch {
userAnimatable.snapTo(Offset.Zero)
val initialScrollX = scrollX
val initialScrollY = scrollY
userAnimatable.animateDecay(
initialVelocity = -Offset(velocityX, velocityY),
animationSpec = flingSpec,
) {
setScroll(
scrollX = initialScrollX + value.x,
scrollY = initialScrollY + value.y
)
}
}
}
override fun onFlingZoom(velocity: Float, centroid: Offset) {
if (!isZoomingEnabled || !isFlingZoomEnabled) return
scope?.launch {
userFloatAnimatable.snapTo(0f)
var previous = 0f
userFloatAnimatable.animateDecay(
initialVelocity = velocity,
animationSpec = flingZoomSpec,
) {
/* Since scale = 2.pow(z - maxLevel) , where z is the zoom level
* taking the derivative: d_scale = ln(2) * scale * d_z */
val newScale = scale + ln(2.0) * scale * (value - previous)
onScaleRatio(newScale / scale, centroid)
previous = value
}
}
}
override fun onTouchDown() {
if (!areGesturesEnabled) return
scope?.launch {
stopAnimations()
}
stateChangeListener.onTouchDown()
}
override fun onPress() {
stateChangeListener.onPress()
}
override fun onTap(focalPt: Offset) {
if (!stateChangeListener.detectsTap()) return
offsetToRelative(focalPt) { x, y ->
stateChangeListener.onTap(x, y)
}
}
override fun onLongPress(focalPt: Offset) {
if (!stateChangeListener.detectsLongPress()) return
offsetToRelative(focalPt) { x, y ->
stateChangeListener.onLongPress(x, y)
}
}
private fun offsetToRelative(focalPt: Offset, block: (Double, Double) -> T): T {
val angleRad = -rotation.toRad()
val focalPtRotated = rotateFocalPoint(focalPt, angleRad)
val x = (scrollX + focalPtRotated.x) / (scale * fullWidth)
val y = (scrollY + focalPtRotated.y) / (scale * fullHeight)
return block(x, y)
}
private fun relativeToMarkerLayoutCoords(x: Double, y: Double, block: (Int, Int) -> T): T {
val xFullPx = x * fullWidth * scale
val yFullPx = y * fullHeight * scale
val centerX = centroidX * fullWidth * scale
val centerY = centroidY * fullHeight * scale
val angleRad = rotation.toRad()
val xPx = (rotateCenteredX(
xFullPx,
yFullPx,
centerX,
centerY,
angleRad
)).toInt()
val yPx = (rotateCenteredY(
xFullPx,
yFullPx,
centerX,
centerY,
angleRad
)).toInt()
return block(xPx, yPx)
}
override fun onDoubleTap(focalPt: Offset) {
if (!isZoomingEnabled) return
val destScale = (
2.0.pow(floor(ln((scale * 2)) / ln(2.0)))
).let {
if (shouldLoopScale && it > maxScale) minScale else it
}
val angleRad = -rotation.toRad()
val focalPtRotated = rotateFocalPoint(focalPt, angleRad)
scope?.launch {
smoothScaleWithFocalPoint(
focalPtRotated.x,
focalPtRotated.y,
destScale,
doubleTapSpec
)
}
}
override fun onTwoFingersTap(focalPt: Offset) {
if (!isZoomingEnabled) return
val destScale = 2.0.pow(floor(ln((scale / 2)) / ln(2.0)))
val angleRad = -rotation.toRad()
val focalPtRotated = rotateFocalPoint(focalPt, angleRad)
scope?.launch {
smoothScaleWithFocalPoint(
focalPtRotated.x,
focalPtRotated.y,
destScale,
doubleTapSpec
)
}
}
override fun isListeningForGestures(): Boolean = areGesturesEnabled
override fun shouldConsumeTapGesture(focalPt: Offset): Boolean {
return offsetToRelative(focalPt) { x, y ->
relativeToMarkerLayoutCoords(x, y) { xPx, yPx ->
stateChangeListener.interceptsTap(x, y, xPx, yPx)
}
}
}
override fun shouldConsumeLongPress(focalPt: Offset): Boolean {
return offsetToRelative(focalPt) { x, y ->
relativeToMarkerLayoutCoords(x, y) { xPx, yPx ->
stateChangeListener.interceptsLongPress(x, y, xPx, yPx)
}
}
}
override fun onSizeChanged(composableScope: CoroutineScope, size: IntSize) {
scope = composableScope
/* When the size changes, typically on device rotation, the scroll needs to be adapted so
* that we keep the same location at the center of the screen. Don't do that when layout
* hasn't been done yet. */
var newScrollX: Double? = null
var newScrollY: Double? = null
if (layoutSize != IntSize.Zero) {
newScrollX = scrollX + (layoutSize.width - size.width) / 2
newScrollY = scrollY + (layoutSize.height - size.height) / 2
}
layoutSize = size
recalculateMinScale()
if (newScrollX != null && newScrollY != null) {
setScroll(newScrollX, newScrollY)
}
/* Layout was done at least once, resume continuations */
for (ct in onLayoutContinuations) {
ct.resume(Unit)
}
onLayoutContinuations.clear()
}
private fun constrainScrollX(scrollX: Double): Double {
val angle = rotation.toRad()
val layoutDimension =
polarRadius(layoutSize.width.toFloat(), layoutSize.height.toFloat(), angle)
val bias = (layoutDimension - layoutSize.width) / 2
return if (infiniteScrollX) {
val left = bias
val right = bias + fullWidth * scale - layoutDimension
val constrained = when {
scrollX < (left - layoutDimension) -> {
val delta = left - layoutDimension - scrollX
val window = right - left + layoutDimension
val ratio = (delta / window).toInt()
right - (delta - ratio * window)
}
scrollX > (right + layoutDimension) -> {
val delta = scrollX - right - layoutDimension
val window = right - left + layoutDimension
val ratio = (delta / window).toInt()
left + (delta - ratio * window)
}
else -> scrollX
}
/* Also update the rollover */
val newRollover = when {
abs(left - layoutDimension - constrained) < rolloverThreshold -> Rollover.Backward
abs(right + layoutDimension - constrained) < rolloverThreshold -> Rollover.Forward
else -> null
}
val current = rolloverX.value
rolloverX.value = if (current == null) {
RolloverData(
current = newRollover ?: Rollover.None(TimeSource.Monotonic.markNow())
)
} else {
when (current.current) {
Rollover.Backward, Rollover.Forward -> {
if (newRollover == null) {
current.copy(
current = Rollover.None(TimeSource.Monotonic.markNow()),
previous = current.current
)
} else current
}
is Rollover.None -> {
if (newRollover == null) {
current
} else {
current.copy(
current = newRollover,
previous = current.current
)
}
}
}
}
constrained
} else {
if (fullWidth * scale < layoutDimension) {
val offset = scrollOffsetRatio.x * fullWidth * scale
scrollX.coerceIn(fullWidth * scale - layoutDimension - offset + bias, offset + bias)
} else {
val offset = scrollOffsetRatio.x * layoutDimension
scrollX.coerceIn(
(-offset + bias).toDouble(),
offset + bias + fullWidth * scale - layoutDimension
)
}
}
}
private fun constrainScrollY(scrollY: Double): Double {
val angle = rotation.toRad()
val layoutDimension =
polarRadius(layoutSize.height.toFloat(), layoutSize.width.toFloat(), angle)
val bias = (layoutDimension - layoutSize.height) / 2
return if (fullHeight * scale < layoutDimension) {
val offset = scrollOffsetRatio.y * fullHeight * scale
scrollY.coerceIn(fullHeight * scale - layoutDimension - offset + bias, offset + bias)
} else {
val offset = scrollOffsetRatio.y * layoutDimension
scrollY.coerceIn(
(-offset + bias).toDouble(),
offset + bias + fullHeight * scale - layoutDimension
)
}
}
internal fun constrainScale(scale: Double): Double {
return scale.coerceIn(max(minScale, Double.MIN_VALUE), maxScale.coerceAtLeast(minScale))
}
private fun updateCentroid() {
pivotX = layoutSize.width.toDouble() / 2
pivotY = layoutSize.height.toDouble() / 2
centroidX = (scrollX + pivotX) / (fullWidth * scale)
centroidY = (scrollY + pivotY) / (fullHeight * scale)
}
private fun recalculateMinScale() {
val minScaleX = layoutSize.width.toDouble() / fullWidth
val minScaleY = layoutSize.height.toDouble() / fullHeight
val mode = minimumScaleMode
minScale = when (mode) {
Fit -> min(minScaleX, minScaleY)
Fill -> max(minScaleX, minScaleY)
is Forced -> mode.scale
}
setScale(scale)
}
private fun notifyStateChanged() {
if (layoutSize != IntSize.Zero) {
stateChangeListener.onStateChanged()
}
}
private fun polarRadius(a: Float, b: Float, angle: AngleRad): Float {
return a * b / sqrt((a * sin(angle)).pow(2) + (b * cos(angle)).pow(2))
}
private val rolloverThreshold = 200.0
}
internal sealed interface Rollover {
data object Forward : Rollover
data object Backward : Rollover
data class None(val timeMark: TimeSource.Monotonic.ValueTimeMark) : Rollover
}
internal data class RolloverData(val current: Rollover, val previous: Rollover? = null)
/**
* The padding to apply when some UI is obscuring the map on it's borders.
*/
internal data class VisibleAreaPadding(val left: Int, val top: Int, val right: Int, val bottom: Int)
interface ZoomPanRotateStateListener {
fun onStateChanged()
fun onTouchDown()
fun onPress()
fun onLongPress(x: Double, y: Double)
fun onTap(x: Double, y: Double)
fun detectsTap(): Boolean
fun detectsLongPress(): Boolean
fun interceptsTap(x: Double, y: Double, xPx: Int, yPx: Int): Boolean
fun interceptsLongPress(x: Double, y: Double, xPx: Int, yPx: Int): Boolean
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/state/markers/MarkerRenderState.kt
================================================
package ovh.plrapps.mapcompose.ui.state.markers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.DpOffset
import ovh.plrapps.mapcompose.ui.state.markers.model.MarkerData
import ovh.plrapps.mapcompose.ui.state.markers.model.MarkerType
import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy
import ovh.plrapps.mapcompose.utils.removeFirst
import kotlin.math.pow
internal class MarkerRenderState {
internal val markers = derivedStateOf {
regularMarkers + lazyLoadedMarkers + clustererManagedMarkers
}
private val hasClickable = derivedStateOf {
markers.value.any {
it.isClickable
}
}
private val regularMarkers = mutableStateListOf()
private val lazyLoadedMarkers = mutableStateListOf()
private val clustererManagedMarkers = mutableStateListOf()
internal val callouts = mutableStateMapOf()
internal var calloutClickCb: MarkerHitCb? = null
fun getRegularMarkers(): List {
return regularMarkers
}
fun addRegularMarkers(markerDataList: List) {
regularMarkers += markerDataList
}
fun removeRegularMarkers(markerDataList: List) {
regularMarkers -= markerDataList
}
fun getClusteredMarkers(): List {
return clustererManagedMarkers
}
fun addClustererManagedMarker(markerData: MarkerData) {
clustererManagedMarkers.add(markerData)
}
fun removeClustererManagedMarker(id: String): Boolean {
return clustererManagedMarkers.removeFirst { it.id == id }
}
fun removeAllClusterManagedMarkers(clusteredId: String) {
clustererManagedMarkers.removeAll { markerData ->
(markerData.renderingStrategy is RenderingStrategy.Clustering)
&& markerData.renderingStrategy.clustererId == clusteredId
}
}
fun getLazyLoadedMarkers(): List {
return lazyLoadedMarkers
}
fun addLazyLoadedMarker(markerData: MarkerData) {
lazyLoadedMarkers.add(markerData)
}
fun removeLazyLoadedMarker(id: String): Boolean {
return lazyLoadedMarkers.removeFirst { it.id == id }
}
fun removeAllLazyLoadedMarkers(lazyLoaderId: String) {
lazyLoadedMarkers.removeAll { markerData ->
(markerData.renderingStrategy is RenderingStrategy.LazyLoading)
&& markerData.renderingStrategy.lazyLoaderId == lazyLoaderId
}
}
fun addCallout(
id: String, x: Double, y: Double, relativeOffset: Offset, absoluteOffset: DpOffset,
zIndex: Float, autoDismiss: Boolean, clickable: Boolean, isConstrainedInBounds: Boolean,
c: @Composable () -> Unit
) {
val markerData =
MarkerData(
id = id,
x = x,
y = y,
relativeOffset = relativeOffset,
absoluteOffset = absoluteOffset,
zIndex = zIndex,
clickable = clickable,
isConstrainedInBounds = isConstrainedInBounds,
clickableAreaScale = Offset(1f, 1f),
clickableAreaCenterOffset = Offset(0f, 0f),
renderingStrategy = RenderingStrategy.Default,
type = MarkerType.Callout,
c = c
)
callouts[id] = CalloutData(markerData, autoDismiss)
}
fun hasCallout(id: String): Boolean = callouts.containsKey(id)
fun moveCallout(id: String, x: Double, y: Double) {
callouts[id]?.markerData?.also {
it.x = if (it.isConstrainedInBounds) x.coerceIn(0.0, 1.0) else x
it.y = if (it.isConstrainedInBounds) y.coerceIn(0.0, 1.0) else y
}
}
fun removeCallout(id: String): Boolean {
return callouts.remove(id) != null
}
fun removeAllAutoDismissCallouts() {
if (callouts.isEmpty()) return
val it = callouts.iterator()
while (it.hasNext()) {
if (it.next().value.autoDismiss) it.remove()
}
}
/**
* Get the nearest marker which contains the click position and has the highest z-index.
*/
fun getMarkerForHit(xPx: Int, yPx: Int): MarkerData? {
if (!hasClickable.value) return null
val candidates = markers.value.filter { markerData ->
markerData.isClickable && markerData.contains(xPx, yPx)
}
val highestZ = candidates.maxByOrNull { it.zIndex }?.zIndex ?: return null
return candidates.filter {
it.zIndex == highestZ
}.minWithOrNull { markerData1, markerData2 ->
if (squareDistance(markerData1, xPx, yPx) > squareDistance(markerData2, xPx, yPx)) 1 else -1
}
}
private fun squareDistance(markerData: MarkerData, x: Int, y: Int): Double {
val (cx, cy) = markerData.getCenter() ?: return Double.MAX_VALUE
return (cx - x).pow(2) + (cy - y).pow(2)
}
internal fun onCalloutClick(data: MarkerData) {
calloutClickCb?.invoke(data.id, data.x, data.y)
}
}
internal data class CalloutData(val markerData: MarkerData, val autoDismiss: Boolean)
internal typealias MarkerMoveCb = (id: String, x: Double, y: Double, dx: Double, dy: Double) -> Unit
internal typealias MarkerHitCb = (id: String, x: Double, y: Double) -> Unit
fun interface DragInterceptor {
/**
* The default behavior (e.g without a drag interceptor) updates the marker coordinates like so:
* * x: [x] + [dx]
* * y: [y] + [dy]
*
* @param id: The id of the marker
* @param x, y: The current normalized coordinates of the marker
* @param dx, dy: The virtual displacement expressed in relative coordinates (not in pixels) that would
* have been applied if there were no drag interceptor
* @param px, py: The current normalized coordinates of the pointer. If the marker's
* "isConstrainedInBounds" property is set to true, these coordinates are coerced in 0.0..1.0
*/
fun onMove(
id: String,
x: Double,
y: Double,
dx: Double,
dy: Double,
px: Double,
py: Double
)
}
fun interface DragStartListener {
/**
* @param id: The id of the marker
* @param x, y: The normalized coordinates of the marker, before the drag starts.
* @param px, py: The current normalized coordinates of the pointer. If the marker's
* "isConstrainedInBounds" property is set to true, these coordinates are coerced in 0.0..1.0
*/
fun onDragStart(id: String, x: Double, y: Double, px: Double, py: Double)
}
fun interface DragEndListener {
/**
* @param id: The id of the marker
* @param x, y: The normalized coordinates of the marker when the drag ends.
*/
fun onDragEnd(id: String, x: Double, y: Double)
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/state/markers/MarkerState.kt
================================================
package ovh.plrapps.mapcompose.ui.state.markers
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import ovh.plrapps.mapcompose.api.ClusterScaleThreshold
import ovh.plrapps.mapcompose.ui.markers.Clusterer
import ovh.plrapps.mapcompose.ui.markers.LazyLoader
import ovh.plrapps.mapcompose.ui.gestures.model.HitType
import ovh.plrapps.mapcompose.ui.state.MapState
import ovh.plrapps.mapcompose.ui.state.markers.model.ClusterClickBehavior
import ovh.plrapps.mapcompose.ui.state.markers.model.MarkerData
import ovh.plrapps.mapcompose.ui.state.markers.model.MarkerType
import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy
internal class MarkerState(
scope: CoroutineScope,
private val markerRenderState: MarkerRenderState
) {
private val markers = MutableStateFlow>(emptyList())
internal var markerClickCb: MarkerHitCb? = null
internal var markerLongPressCb: MarkerHitCb? = null
internal var markerMoveCb: MarkerMoveCb? = null
private val clusterersById = mutableMapOf()
private val lazyLoaderById = mutableMapOf()
init {
scope.launch {
renderRegularMarkers()
}
}
fun hasMarker(id: String): Boolean = markers.value.any { it.id == id }
fun getMarker(id: String): MarkerData? {
return markers.value.firstOrNull { it.id == id }
}
fun getRenderedMarkers(): List = markerRenderState.markers.value
fun addMarker(
id: String, x: Double, y: Double,
relativeOffset: Offset,
absoluteOffset: DpOffset,
zIndex: Float,
clickable: Boolean,
isConstrainedInBounds: Boolean,
clickableAreaScale: Offset,
clickableAreaCenterOffset: Offset,
renderingStrategy: RenderingStrategy,
c: @Composable () -> Unit
) {
if (hasMarker(id)) return
markers.value += MarkerData(
id = id,
x = x,
y = y,
relativeOffset = relativeOffset,
absoluteOffset = absoluteOffset,
zIndex = zIndex,
clickable = clickable,
isConstrainedInBounds = isConstrainedInBounds,
clickableAreaScale = clickableAreaScale,
clickableAreaCenterOffset = clickableAreaCenterOffset,
renderingStrategy = renderingStrategy,
type = MarkerType.Marker,
c = c
)
}
fun removeMarker(id: String): Boolean {
return getMarker(id)?.let {
markers.value = markers.value - it
true
} ?: false
}
fun removeAllMarkers() {
markers.value = emptyList()
}
/**
* Move a marker by the provided delta (normalized) coordinates.
*/
fun moveMarkerBy(id: String, deltaX: Double, deltaY: Double) {
getMarker(id)?.apply {
x = (x + deltaX).let {
if (isConstrainedInBounds) it.coerceIn(0.0, 1.0) else it
}
y = (y + deltaY).let {
if (isConstrainedInBounds) it.coerceIn(0.0, 1.0) else it
}
}.also {
if (it != null) onMarkerMove(it, deltaX, deltaY)
}
}
fun moveMarkerTo(id: String, x: Double, y: Double) {
val marker = getMarker(id) ?: return
moveMarkerTo(marker, x, y)
}
fun moveMarkerTo(markerData: MarkerData, x: Double, y: Double) {
with(markerData) {
val prevX = x
val prevY = y
this.x = if (isConstrainedInBounds) x.coerceIn(0.0, 1.0) else x
this.y = if (isConstrainedInBounds) y.coerceIn(0.0, 1.0) else y
onMarkerMove(this, this.x - prevX, this.y - prevY)
}
}
/**
* If set, drag gestures will be handled for the marker identifiable by the [id].
*/
fun setDraggable(id: String, draggable: Boolean) {
getMarker(id)?.isDraggable = draggable
}
private fun onMarkerMove(data: MarkerData, dx: Double, dy: Double) {
markerMoveCb?.invoke(data.id, data.x, data.y, dx, dy)
}
fun addClusterer(
mapState: MapState,
id: String,
clusteringThreshold: Dp,
clusterClickBehavior: ClusterClickBehavior,
scaleThreshold: ClusterScaleThreshold,
clusterFactory: (ids: List) -> (@Composable () -> Unit)
) {
val clusterer = Clusterer(
id = id,
clusteringThreshold = clusteringThreshold,
mapState = mapState,
markerRenderState = markerRenderState,
markersDataFlow = markers,
clusterClickBehavior = clusterClickBehavior,
scaleThreshold = scaleThreshold,
clusterFactory = clusterFactory
)
clusterersById[id] = clusterer
}
fun setClusteredExemptList(id: String, markersToExempt: Set) {
clusterersById[id]?.apply {
exemptionSet.value = markersToExempt
}
}
fun addLazyLoader(
mapState: MapState,
id: String,
padding: Dp
) {
val lazyLoader = LazyLoader(
id, mapState, markerRenderState, markers, padding, mapState.scope
)
lazyLoaderById[id] = lazyLoader
}
fun removeClusterer(id: String, removeManaged: Boolean) {
clusterersById[id]?.apply {
cancel(removeManaged = removeManaged)
}
clusterersById.remove(id)
if (removeManaged) {
removeAll {
(it.renderingStrategy is RenderingStrategy.Clustering) &&
(it.renderingStrategy.clustererId == id)
}
}
}
fun removeLazyLoader(id: String, removeManaged: Boolean) {
lazyLoaderById[id]?.apply {
cancel(removeManaged = removeManaged)
}
if (removeManaged) {
removeAll {
(it.renderingStrategy is RenderingStrategy.LazyLoading) &&
(it.renderingStrategy.lazyLoaderId == id)
}
}
}
fun onHit(x: Int, y: Int, hitType: HitType): Boolean {
return markerRenderState.getMarkerForHit(x, y)?.also { markerData ->
/* If it's a cluster, run the corresponding click behavior. */
if (markerData.type is MarkerType.Cluster && hitType == HitType.Click) {
val clusterer = clusterersById[markerData.type.clustererId]
clusterer?.onPlaceableClick(markerData)
} else {
/* It's not a cluster. Invoke user callback, if any. */
when (hitType) {
HitType.Click -> markerClickCb?.invoke(markerData.id, markerData.x, markerData.y)
HitType.LongPress -> markerLongPressCb?.invoke(markerData.id, markerData.x, markerData.y)
}
}
} != null
}
private fun removeAll(predicate: (MarkerData) -> Boolean) {
markers.value = markers.value.filterNot {
predicate(it)
}
}
private suspend fun renderRegularMarkers() {
markers.collect {
val regular = it.filter { markerData ->
markerData.renderingStrategy is RenderingStrategy.Default
}
val rendered = markerRenderState.getRegularMarkers()
val toAdd = regular - rendered
val toRemove = rendered - regular
markerRenderState.addRegularMarkers(toAdd)
markerRenderState.removeRegularMarkers(toRemove)
}
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/state/markers/model/ClusterClickBehavior.kt
================================================
package ovh.plrapps.mapcompose.ui.state.markers.model
internal sealed interface ClusterClickBehavior
internal data object Default : ClusterClickBehavior
internal data class Custom(
val withDefaultBehavior: Boolean = false,
val onClick: (ClusterInfo) -> Unit
) : ClusterClickBehavior
internal data object None : ClusterClickBehavior
internal data class ClusterInfo(val x: Double, val y: Double, val markers: List)
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/state/markers/model/MarkerData.kt
================================================
package ovh.plrapps.mapcompose.ui.state.markers.model
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.DpOffset
import ovh.plrapps.mapcompose.ui.state.markers.DragEndListener
import ovh.plrapps.mapcompose.ui.state.markers.DragInterceptor
import ovh.plrapps.mapcompose.ui.state.markers.DragStartListener
import ovh.plrapps.mapcompose.utils.Point
import java.util.*
internal class MarkerData(
val id: String,
x: Double, y: Double,
relativeOffset: Offset,
absoluteOffset: DpOffset,
zIndex: Float,
clickable: Boolean,
isConstrainedInBounds: Boolean,
clickableAreaScale: Offset,
clickableAreaCenterOffset: Offset,
val renderingStrategy: RenderingStrategy,
val type: MarkerType,
val c: @Composable () -> Unit
) {
var x: Double by mutableDoubleStateOf(x)
var y: Double by mutableDoubleStateOf(y)
var relativeOffset by mutableStateOf(relativeOffset)
var absoluteOffset by mutableStateOf(absoluteOffset)
var isDraggable by mutableStateOf(false)
var dragStartListener: DragStartListener? by mutableStateOf(null)
var dragEndListener: DragEndListener? by mutableStateOf(null)
var dragInterceptor: DragInterceptor? by mutableStateOf(null)
var isClickable: Boolean by mutableStateOf(clickable)
var clickableAreaScale by mutableStateOf(clickableAreaScale)
var clickableAreaCenterOffset by mutableStateOf(clickableAreaCenterOffset)
var isVisible: Boolean by mutableStateOf(true)
var zIndex: Float by mutableFloatStateOf(zIndex)
var isConstrainedInBounds by mutableStateOf(isConstrainedInBounds)
var measuredWidth = 0
var measuredHeight = 0
var xPlacement: Double? = null
var yPlacement: Double? = null
val uuid: UUID = UUID.randomUUID()
fun contains(x: Int, y: Int): Boolean {
val (centerX, centerY) = getCenter() ?: return false
val deltaX = measuredWidth * clickableAreaScale.x / 2
val deltaY = measuredHeight * clickableAreaScale.y / 2
return (x >= centerX - deltaX && x <= centerX + deltaX
&& y >= centerY - deltaY && y <= centerY + deltaY)
}
fun getCenter(): Point? {
val xPos = xPlacement ?: return null
val yPos = yPlacement ?: return null
val centerX = xPos + measuredWidth / 2 + measuredWidth * clickableAreaCenterOffset.x
val centerY = yPos + measuredHeight / 2 + measuredHeight * clickableAreaCenterOffset.y
return Point(centerX, centerY)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MarkerData
return (id == other.id && x == other.x && y == other.y && uuid == other.uuid)
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + x.hashCode()
result = 31 * result + y.hashCode()
return result
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/state/markers/model/MarkerType.kt
================================================
package ovh.plrapps.mapcompose.ui.state.markers.model
internal sealed interface MarkerType {
data object Marker : MarkerType
data object Callout : MarkerType
data class Cluster(val clustererId: String, val markersData: List) : MarkerType
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/state/markers/model/RenderingStrategy.kt
================================================
package ovh.plrapps.mapcompose.ui.state.markers.model
sealed interface RenderingStrategy {
data object Default : RenderingStrategy
data class Clustering(val clustererId: String) : RenderingStrategy
data class LazyLoading(val lazyLoaderId: String) : RenderingStrategy
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/ui/view/TileCanvas.kt
================================================
package ovh.plrapps.mapcompose.ui.view
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.asAndroidColorFilter
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.nativeCanvas
import ovh.plrapps.mapcompose.core.ColorFilterProvider
import ovh.plrapps.mapcompose.core.Tile
import ovh.plrapps.mapcompose.core.VisibleTilesResolver
import ovh.plrapps.mapcompose.ui.layout.grid
import ovh.plrapps.mapcompose.ui.state.Rollover
import ovh.plrapps.mapcompose.ui.state.RolloverData
import ovh.plrapps.mapcompose.ui.state.ZoomPanRotateState
import kotlin.math.ceil
import kotlin.time.TimeSource
@Composable
internal fun TileCanvas(
modifier: Modifier,
zoomPRState: ZoomPanRotateState,
visibleTilesResolver: VisibleTilesResolver,
tileSize: Int,
alphaTick: Float,
colorFilterProvider: ColorFilterProvider?,
tilesToRender: List,
isFilteringBitmap: () -> Boolean,
) {
val dest = remember { Rect() }
val paint: Paint = remember {
Paint().apply {
isAntiAlias = false
}
}
Canvas(
modifier = modifier
.fillMaxSize()
) {
/* Scroll values may not be represented accurately using floats (a float has 7 significant
* decimal digits, so any number above ~10M isn't represented accurately).
* Since the translate function of the Canvas works with floats, we perform a change of
* referential so that we only need to translate the canvas by an amount which can be
* precisely represented as a float. */
val x0 = ((ceil(zoomPRState.scrollX / grid) * grid) / zoomPRState.scale).toInt()
val y0 = ((ceil(zoomPRState.scrollY / grid) * grid) / zoomPRState.scale).toInt()
withTransform({
/* Geometric transformations seem to be applied in reversed order of declaration */
rotate(
degrees = zoomPRState.rotation,
pivot = Offset(
x = zoomPRState.pivotX.toFloat(),
y = zoomPRState.pivotY.toFloat()
)
)
translate(
left = (-zoomPRState.scrollX + x0 * zoomPRState.scale).toFloat(),
top = (-zoomPRState.scrollY + y0 * zoomPRState.scale).toFloat()
)
scale(scale = zoomPRState.scale.toFloat(), Offset.Zero)
}) {
paint.isFilterBitmap = isFilteringBitmap()
val rolloverX = zoomPRState.rolloverX.value
for (tile in tilesToRender) {
if (tile.markedForSweep) continue
val bitmap = tile.bitmap ?: continue
val scaleForLevel = visibleTilesResolver.getScaleForLevel(tile.zoom)
?: continue
val tileScaled = (tileSize / scaleForLevel).toInt()
val phases = tile.phases.applyRolloverX(rolloverX, tile.timeMark)
if (phases == null) {
drawTile(
tile = tile,
tileScaled = tileScaled,
phi = 0,
x0 = x0,
y0 = y0,
dest = dest,
colorFilterProvider = colorFilterProvider,
paint = paint,
bitmap = bitmap,
)
} else {
val colCount = visibleTilesResolver.getColCountForLevel(tile.zoom) ?: continue
for (i in phases) {
drawTile(
tile = tile,
tileScaled = tileScaled,
phi = i * colCount,
x0 = x0,
y0 = y0,
dest = dest,
colorFilterProvider = colorFilterProvider,
paint = paint,
bitmap = bitmap,
)
}
}
/* If a tile isn't fully opaque, increase its alpha state by the alpha tick */
if (tile.alpha < 1f) {
tile.alpha = (tile.alpha + alphaTick).coerceAtMost(1f)
} else {
tile.overlaps?.markedForSweep = true
tile.overlaps = null
}
}
}
}
}
private fun DrawScope.drawTile(
tile: Tile,
tileScaled: Int,
phi: Int,
x0: Int,
y0: Int,
dest: Rect,
colorFilterProvider: ColorFilterProvider?,
paint: Paint,
bitmap: Bitmap,
) {
val l = tile.col * tileScaled + phi * tileScaled
val t = tile.row * tileScaled
val r = l + tileScaled
val b = t + tileScaled
/* The change of referential is done by offsetting coordinates by (x0, y0) */
dest.set(l - x0, t - y0, r - x0, b - y0)
val colorFilter = colorFilterProvider?.getColorFilter(tile.row, tile.col, tile.zoom)
paint.alpha = (tile.alpha * 255).toInt()
paint.colorFilter = colorFilter?.asAndroidColorFilter()
drawIntoCanvas {
it.nativeCanvas.drawBitmap(bitmap, null, dest, paint)
}
}
private fun IntRange?.applyRolloverX(rolloverData: RolloverData?, timeMark: TimeSource.Monotonic.ValueTimeMark?): IntRange? {
return if (rolloverData == null || timeMark == null) {
this
} else {
val rollover = getAppliedRollover(rolloverData, timeMark) ?: return this
if (this == null) {
when (rollover) {
Rollover.Forward -> -1..0
Rollover.Backward -> 0..1
is Rollover.None -> null
}
} else {
when (rollover) {
Rollover.Forward -> IntRange(first - 1, last)
Rollover.Backward -> IntRange(first, last + 1)
is Rollover.None -> this
}
}
}
}
/**
* Apply [Rollover.None] only when the tile originates from a snapshot made _after_ the rollover.
* Otherwise, when the tile originates from a snapshot made _before_ the rollover, the tile's phases
* should be applied either [Rollover.Forward] or [Rollover.Backward] (depending on the direction
* of the scroll).
*/
private fun getAppliedRollover(rolloverData: RolloverData, timeMark: TimeSource.Monotonic.ValueTimeMark): Rollover? {
return if (rolloverData.current is Rollover.None) {
if (timeMark > rolloverData.current.timeMark) {
rolloverData.current
} else {
rolloverData.previous
}
} else {
rolloverData.current
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/utils/AnimUtils.kt
================================================
package ovh.plrapps.mapcompose.utils
/**
* Calculates a number between two numbers at a specific increment.
*/
fun lerp(a: Float, b: Float, t: Float): Float {
return a + (b - a) * t
}
fun lerp(a: Double, b: Double, t: Double): Double {
return a + (b - a) * t
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/utils/ApiUtils.kt
================================================
package ovh.plrapps.mapcompose.utils
import kotlinx.coroutines.delay
internal suspend fun withRetry(maxRetry: Int, intervalMs: Long, block: suspend () -> Boolean) {
var cnt = 0
var res = block()
while (!res && cnt < maxRetry) {
delay(intervalMs)
res = block()
cnt++
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/utils/BoundingBoxUtils.kt
================================================
package ovh.plrapps.mapcompose.utils
import ovh.plrapps.mapcompose.api.BoundingBox
internal fun BoundingBox.scaleAxis(xAxisMultiplier: Double): BoundingBox {
return BoundingBox(xLeft * xAxisMultiplier, yTop, xRight * xAxisMultiplier, yBottom)
}
internal fun BoundingBox.rotate(center: Point, angle: AngleRad): BoundingBox {
val topLeft = Point(xLeft, yTop)
val topRight = Point(xRight, yTop)
val bottomLeft = Point(xLeft, yBottom)
val bottomRight = Point(xRight, yBottom)
val points = listOf(topLeft, topRight, bottomLeft, bottomRight)
val rotatedPoints = rotateCentered(points, center, angle)
val left = rotatedPoints.minOf { it.x }
val top = rotatedPoints.minOf { it.y }
val right = rotatedPoints.maxOf { it.x }
val bottom = rotatedPoints.maxOf { it.y }
return BoundingBox(left, top, right, bottom)
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/utils/Collections.kt
================================================
package ovh.plrapps.mapcompose.utils
fun MutableCollection.removeFirst(predicate: (T) -> Boolean): Boolean {
var removed = false
val it = iterator()
while (it.hasNext()) {
if (predicate(it.next())) {
it.remove()
removed = true
break
}
}
return removed
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/utils/Dp.kt
================================================
package ovh.plrapps.mapcompose.utils
import android.content.res.Resources
fun dpToPx(dp: Float): Float = dp * Resources.getSystem().displayMetrics.density
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/utils/Flow.kt
================================================
package ovh.plrapps.mapcompose.utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ovh.plrapps.mapcompose.utils.map
fun Flow.throttle(wait: Long) = channelFlow {
val channel = Channel(capacity = Channel.CONFLATED)
coroutineScope {
launch {
collect {
channel.send(it)
}
}
launch {
for (e in channel) {
send(e)
delay(wait)
}
}
}
}
fun StateFlow.map(
coroutineScope : CoroutineScope,
mapper : (value : T) -> M
) : StateFlow = map { mapper(it) }.stateIn(
coroutineScope,
SharingStarted.Eagerly,
mapper(value)
)
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/utils/Geometry.kt
================================================
package ovh.plrapps.mapcompose.utils
import kotlin.math.hypot
internal fun getDistance(x: Double, y: Double, x1: Double, y1: Double, x2: Double, y2: Double): Double {
val a = x - x1
val b = y - y1
val c = x2 - x1
val d = y2 - y1
val lenSq = c * c + d * d
val param = if (lenSq != 0.0) {
val dot = a * c + b * d
dot / lenSq
} else {
-1.0
}
val (xx, yy) = when {
param < 0.0 -> x1 to y1
param > 1.0 -> x2 to y2
else -> x1 + param * c to y1 + param * d
}
val dx = x - xx
val dy = y - yy
return hypot(dx, dy)
}
internal fun getNearestPoint(
x: Double,
y: Double,
x1: Double,
y1: Double,
x2: Double,
y2: Double
): Point {
val a = x - x1
val b = y - y1
val c = x2 - x1
val d = y2 - y1
val lenSq = c * c + d * d
val param = if (lenSq != 0.0) {
val dot = a * c + b * d
dot / lenSq
} else {
-1.0
}
val (xx, yy) = when {
param < 0.0 -> x1 to y1
param > 1.0 -> x2 to y2
else -> x1 + param * c to y1 + param * d
}
return Point(xx, yy)
}
internal fun isInsideBox(x: Double, y: Double, xMin: Double, xMax: Double, yMin: Double, yMax: Double): Boolean {
return x in xMin..xMax && y in yMin..yMax
}
internal fun getDistanceFromBox(x: Double, y: Double, xMin: Double, xMax: Double, yMin: Double, yMax: Double): Double {
return when {
x < xMin -> getDistance(x, y, xMin, yMin, xMin, yMax)
x > xMax -> getDistance(x, y, xMax, yMin, xMax, yMax)
y < yMin -> getDistance(x, y, xMin, yMin, xMax, yMin)
y > yMax -> getDistance(x, y, xMin, yMax, xMax, yMax)
else -> 0.0 // inside
}
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/utils/Point.kt
================================================
package ovh.plrapps.mapcompose.utils
data class Point(val x: Double, val y: Double)
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/utils/RotationUtils.kt
================================================
package ovh.plrapps.mapcompose.utils
import kotlin.math.cos
import kotlin.math.sin
typealias AngleDegree = Float
typealias AngleRad = Float
fun AngleDegree.toRad(): AngleRad = this * 0.017453292519943295f // this * PI / 180.0
/**
* Constrain the angle to have values between 0f and 360f.
*/
fun AngleDegree.modulo(): AngleDegree {
val mod = this % 360f
return if (mod < 0) {
mod + 360f
} else mod
}
fun rotateX(x: Double, y: Double, angleRad: AngleRad): Double {
return x * cos(angleRad) - y * sin(angleRad)
}
fun rotateY(x: Double, y: Double, angleRad: AngleRad): Double {
return x * sin(angleRad) + y * cos(angleRad)
}
fun rotateCentered(points: List, center: Point, angleRad: AngleRad): List {
return points.map { rotateCentered(it, center, angleRad) }
}
fun rotateCentered(point: Point, center: Point, angleRad: AngleRad): Point {
return Point(rotateCenteredX(point, center, angleRad), rotateCenteredY(point, center, angleRad))
}
fun rotateCenteredX(point: Point, center: Point, angleRad: AngleRad): Double {
return rotateCenteredX(point.x, point.y, center.x, center.y, angleRad)
}
fun rotateCenteredY(point: Point, center: Point, angleRad: AngleRad): Double {
return rotateCenteredY(point.x, point.y, center.x, center.y, angleRad)
}
fun rotateCenteredX(x: Double, y: Double, centerX: Double, centerY: Double, angleRad: AngleRad): Double {
return centerX + (x - centerX) * cos(angleRad) - (y - centerY) * sin(angleRad)
}
fun rotateCenteredY(x: Double, y: Double, centerX: Double, centerY: Double, angleRad: AngleRad): Double {
return centerY + (x - centerX) * sin(angleRad) + (y - centerY) * cos(angleRad)
}
================================================
FILE: mapcompose/src/main/java/ovh/plrapps/mapcompose/utils/VisibleAreaUtils.kt
================================================
package ovh.plrapps.mapcompose.utils
import ovh.plrapps.mapcompose.api.VisibleArea
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
fun VisibleArea.contains(x: Double, y: Double): Boolean {
val fullArea =
triangleArea(p1x, p1y, p2x, p2y, p3x, p3y) + triangleArea(p1x, p1y, p4x, p4y, p3x, p3y)
val t1 = triangleArea(x, y, p1x, p1y, p2x, p2y)
val t2 = triangleArea(x, y, p2x, p2y, p3x, p3y)
val t3 = triangleArea(x, y, p3x, p3y, p4x, p4y)
val t4 = triangleArea(x, y, p1x, p1y, p4x, p4y)
return abs(fullArea - (t1 + t2 + t3 + t4)) < 1E-8
}
private fun triangleArea(
x1: Double,
y1: Double,
x2: Double,
y2: Double,
x3: Double,
y3: Double
): Double {
return abs((x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) / 2.0)
}
fun VisibleArea.intersects(other: VisibleArea): Boolean {
if (
other.contains(p1x, p1y) ||
other.contains(p2x, p2y) ||
other.contains(p3x, p3y) ||
other.contains(p4x, p4y)
) return true
if (
contains(other.p1x, other.p1y) ||
contains(other.p2x, other.p2y) ||
contains(other.p3x, other.p3y) ||
contains(other.p4x, other.p4y)
) return true
if (
segmentsIntersect(p1x, p1y, p3x, p3y, other.p1x, other.p1y, other.p3x, other.p3y)
) return true
return false
}
/**
* Checks whether the two segments [p1;p2] and [p3;p4] intersect.
*/
private fun segmentsIntersect(
p1x: Double,
p1y: Double,
p2x: Double,
p2y: Double,
p3x: Double,
p3y: Double,
p4x: Double,
p4y: Double
): Boolean {
val o1 = orientation(p1x, p1y, p2x, p2y, p3x, p3y)
val o2 = orientation(p1x, p1y, p2x, p2y, p4x, p4y)
val o3 = orientation(p3x, p3y, p4x, p4y, p1x, p1y)
val o4 = orientation(p3x, p3y, p4x, p4y, p2x, p2y)
// General case
if (o1 != o2 && o3 != o4) return true
// p1, q1 and p2 are collinear and p2 lies on segment [p1;q1]
if (o1 == 0 && onSegment(p1x, p1y, p3x, p3y, p2x, p2y)) return true
// p1, q1 and q2 are collinear and q2 lies on segment [p1;q1]
if (o2 == 0 && onSegment(p1x, p1y, p4x, p4y, p2x, p2y)) return true
// p2, q2 and p1 are collinear and p1 lies on segment [p2;q2]
if (o3 == 0 && onSegment(p3x, p3y, p1x, p1y, p4x, p4y)) return true
// p2, q2 and q1 are collinear and q1 lies on segment [p2;q2]
if (o4 == 0 && onSegment(p3x, p3y, p2x, p2y, p4x, p4y)) return true
return false
}
/**
* Given three collinear points p1, p2, p3, check if point p2 lies on line segment [p1;p2]]
*/
private fun onSegment(
p1x: Double,
p1y: Double,
p2x: Double,
p2y: Double,
p3x: Double,
p3y: Double
): Boolean {
return p2x <= max(p1x, p3x) && p2x >= min(p1x, p3x) && p2y <= max(p1y, p3y) && p2y >= min(p1y, p3y)
}
/**
* Get the orientation of ordered triplet (p1, p2, p3).
* @returns 0 when p1, p2 and p3 are collinear
* 1 when Clockwise
* 2 when Counterclockwise
*/
private fun orientation(
p1x: Double,
p1y: Double,
p2x: Double,
p2y: Double,
p3x: Double,
p3y: Double
): Int {
val p = (p2y - p1y) * (p3x - p2x) - (p2x - p1x) * (p3y - p2y)
return if (p == 0.0) 0 else if (p > 0) 1 else 2
}
================================================
FILE: mapcompose/src/test/java/ovh/plrapps/mapcompose/core/TileCollectorTest.kt
================================================
package ovh.plrapps.mapcompose.core
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import java.io.FileInputStream
/**
* Test the [TileCollector.collectTiles] engine. The following assertions are tested:
* * If [TileSpec]s are send to the input channel, corresponding [Tile]s are received from the
* output channel (from the [TileCollector.collectTiles] point of view).
* * The [Bitmap] of the [Tile]s produced should be consistent with the output of the flow
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P])
class TileCollectorTest {
private val tileSize = 256
companion object {
private var assetsDir: File? = null
init {
try {
val mapviewDirURL = TileCollectorTest::class.java.classLoader!!.getResource("tiles")
assetsDir = File(mapviewDirURL.toURI())
} catch (e: Exception) {
println("No tiles directory found.")
}
}
}
@Test
fun fullTest() = runTest {
assertNotNull(assetsDir)
val imageFile = File(assetsDir, "10.jpg")
assertTrue(imageFile.exists())
/* Setup the channels */
val visibleTileLocationsChannel = Channel(capacity = Channel.RENDEZVOUS)
val tilesOutput = Channel(capacity = Channel.RENDEZVOUS)
val tileStreamProvider = TileStreamProvider { _, _, _ -> FileInputStream(imageFile) }
val bitmapReference = try {
val inputStream = FileInputStream(imageFile)
BitmapFactory.decodeStream(inputStream, null, null)
} catch (e: Exception) {
fail()
error("Could not decode image")
}
val layers = listOf(
Layer("default", tileStreamProvider)
)
/* Start collecting tiles */
val tileCollector = TileCollector(1, optimizeForLowEndDevices = false, tileSize)
val tileCollectorJob = launch {
tileCollector.collectTiles(visibleTileLocationsChannel, tilesOutput, layers)
}
fun CoroutineScope.consumeTiles(tileChannel: ReceiveChannel) = launch {
var receivedTiles = 0
for (tile in tileChannel) {
println("received tile ${tile.zoom}-${tile.row}-${tile.col}")
assertTrue(tile.bitmap?.sameAs(bitmapReference) ?: false)
receivedTiles += 1
if (tile.zoom == 6 && tile.row == 6 && tile.col == 6) {
println("received poison pill")
assertEquals(7, receivedTiles)
cancel()
tileCollectorJob.cancel()
}
}
}
/* Start consuming tiles */
consumeTiles(tilesOutput)
launch {
val locations1 = listOf(
TileSpec(0, 0, 0),
TileSpec(0, 1, 1),
TileSpec(0, 2, 1)
)
for (spec in locations1) {
visibleTileLocationsChannel.send(spec)
}
val locations2 = listOf(
TileSpec(1, 0, 0),
TileSpec(1, 1, 1),
TileSpec(1, 2, 1),
TileSpec(6, 6, 6), // poison pill
)
for (spec in locations2) {
visibleTileLocationsChannel.send(spec)
}
}
Unit
}
}
================================================
FILE: mapcompose/src/test/java/ovh/plrapps/mapcompose/core/VisibleTilesResolverTest.kt
================================================
package ovh.plrapps.mapcompose.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import ovh.plrapps.mapcompose.core.VisibleTilesResolver.*
import kotlin.math.pow
class VisibleTilesResolverTest {
private var scale = 1.0
private val scaleProvider = ScaleProvider { scale }
@Test
fun levelTest() {
val resolver = VisibleTilesResolver(
levelCount = 8,
fullWidth = 1000,
fullHeight = 800,
tileSize = 256,
magnifyingFactor = 0,
infiniteScrollX = false,
scaleProvider = scaleProvider
)
assertEquals(7, resolver.getLevel(1.0))
assertEquals(7, resolver.getLevel(0.7))
assertEquals(6, resolver.getLevel(0.5))
assertEquals(6, resolver.getLevel(0.26))
assertEquals(5, resolver.getLevel(0.15))
assertEquals(0, resolver.getLevel(0.0078))
assertEquals(1, resolver.getLevel(0.008))
/* Outside of bounds test */
assertEquals(0, resolver.getLevel(0.0030))
assertEquals(7, resolver.getLevel(1.0))
}
@Test
fun subSampleTest() {
val resolver = VisibleTilesResolver(
levelCount = 8,
fullWidth = 1000,
fullHeight = 800,
tileSize = 256,
magnifyingFactor = 0,
infiniteScrollX = false,
scaleProvider = scaleProvider
)
assertEquals(0, resolver.getSubSample(0.008))
assertEquals(1, resolver.getSubSample(0.0078)) // 0.0078 is the scale of level 0
/* Outside of bounds: subsample should be at least 1 */
assertEquals(1, resolver.getSubSample(1.0 / 2.0.pow(7.5)))
assertEquals(2, resolver.getSubSample(1.0 / 2.0.pow(9)))
assertEquals(3, resolver.getSubSample(1.0 / 2.0.pow(10)))
}
@Test
fun infiniteScrollXTest() {
val resolver = VisibleTilesResolver(
levelCount = 3,
fullWidth = 1024,
fullHeight = 800,
tileSize = 256,
magnifyingFactor = 0,
infiniteScrollX = true,
scaleProvider = scaleProvider
)
scale = 1.0
var viewport = Viewport(-256, 0, 512, 768)
var visibleTiles = resolver.getVisibleTiles(viewport)
var visibleWindow = visibleTiles.visibleWindow as VisibleWindow.InfiniteScrollX
val tileMatrix = visibleWindow.tileMatrix
with(tileMatrix.toTileRange()) {
assertEquals(2, visibleTiles.level)
assertEquals(0, colLeft)
assertEquals(1, colRight)
assertEquals(0, rowTop)
assertEquals(2, rowBottom)
}
var overflowLeft = visibleWindow.leftOverflow?.tileMatrix?.toTileRange()
with(overflowLeft!!) {
assertEquals(2, visibleTiles.level)
assertEquals(3, colLeft)
assertEquals(3, colRight)
assertEquals(0, rowTop)
assertEquals(2, rowBottom)
}
var phaseLeft = visibleWindow.leftOverflow?.phase
assertEquals(mapOf(3 to -1), phaseLeft)
var overflowRight = visibleWindow.rightOverflow?.tileMatrix?.toTileRange()
assertTrue(overflowRight == null)
/* ------------------------------------------------------------------ */
viewport = Viewport(-513, 0, 512, 768)
visibleTiles = resolver.getVisibleTiles(viewport)
visibleWindow = visibleTiles.visibleWindow as VisibleWindow.InfiniteScrollX
overflowLeft = visibleWindow.leftOverflow?.tileMatrix?.toTileRange()
with(overflowLeft!!) {
assertEquals(2, visibleTiles.level)
assertEquals(1, colLeft)
assertEquals(3, colRight)
assertEquals(0, rowTop)
assertEquals(2, rowBottom)
}
phaseLeft = visibleWindow.leftOverflow?.phase
assertEquals(mapOf(3 to -1, 2 to -1, 1 to -1), phaseLeft)
overflowRight = visibleWindow.rightOverflow?.tileMatrix?.toTileRange()
assertTrue(overflowRight == null)
/* ------------------------------------------------------------------ */
viewport = Viewport(-1024, 0, 512, 768)
visibleTiles = resolver.getVisibleTiles(viewport)
visibleWindow = visibleTiles.visibleWindow as VisibleWindow.InfiniteScrollX
overflowLeft = visibleWindow.leftOverflow?.tileMatrix?.toTileRange()
with(overflowLeft!!) {
assertEquals(2, visibleTiles.level)
assertEquals(0, colLeft)
assertEquals(3, colRight)
assertEquals(0, rowTop)
assertEquals(2, rowBottom)
}
phaseLeft = visibleWindow.leftOverflow?.phase
assertEquals(mapOf(3 to -1, 2 to -1, 1 to -1, 0 to -1), phaseLeft)
overflowRight = visibleWindow.rightOverflow?.tileMatrix?.toTileRange()
assertTrue(overflowRight == null)
/* ------------------------------------------------------------------ */
viewport = Viewport(-1792, 0, 512, 768)
visibleTiles = resolver.getVisibleTiles(viewport)
visibleWindow = visibleTiles.visibleWindow as VisibleWindow.InfiniteScrollX
overflowLeft = visibleWindow.leftOverflow?.tileMatrix?.toTileRange()
with(overflowLeft!!) {
assertEquals(2, visibleTiles.level)
assertEquals(0, colLeft)
assertEquals(3, colRight)
assertEquals(0, rowTop)
assertEquals(2, rowBottom)
}
phaseLeft = visibleWindow.leftOverflow?.phase
assertEquals(mapOf(3 to -2, 2 to -2, 1 to -2, 0 to -1), phaseLeft)
overflowRight = visibleWindow.rightOverflow?.tileMatrix?.toTileRange()
assertTrue(overflowRight == null)
/* ------------------------------------------------------------------ */
viewport = Viewport(-2049, 0, 512, 768)
visibleTiles = resolver.getVisibleTiles(viewport)
visibleWindow = visibleTiles.visibleWindow as VisibleWindow.InfiniteScrollX
overflowLeft = visibleWindow.leftOverflow?.tileMatrix?.toTileRange()
with(overflowLeft!!) {
assertEquals(2, visibleTiles.level)
assertEquals(0, colLeft)
assertEquals(3, colRight)
assertEquals(0, rowTop)
assertEquals(2, rowBottom)
}
phaseLeft = visibleWindow.leftOverflow?.phase
assertEquals(mapOf(3 to -3, 2 to -2, 1 to -2, 0 to -2), phaseLeft)
overflowRight = visibleWindow.rightOverflow?.tileMatrix?.toTileRange()
assertTrue(overflowRight == null)
/* ------------------------------------------------------------------ */
viewport = Viewport(0, 0, 1024 + 512, 768)
visibleTiles = resolver.getVisibleTiles(viewport)
visibleWindow = visibleTiles.visibleWindow as VisibleWindow.InfiniteScrollX
assertTrue(visibleWindow.leftOverflow == null)
overflowRight = visibleWindow.rightOverflow?.tileMatrix?.toTileRange()
with(overflowRight!!) {
assertEquals(2, visibleTiles.level)
assertEquals(0, colLeft)
assertEquals(1, colRight)
assertEquals(0, rowTop)
assertEquals(2, rowBottom)
}
var phaseRight = visibleWindow.rightOverflow?.phase
assertEquals(mapOf(0 to 1, 1 to 1), phaseRight)
/* ------------------------------------------------------------------ */
viewport = Viewport(0, 0, 1024 + 1025, 768)
visibleTiles = resolver.getVisibleTiles(viewport)
visibleWindow = visibleTiles.visibleWindow as VisibleWindow.InfiniteScrollX
assertTrue(visibleWindow.leftOverflow == null)
overflowRight = visibleWindow.rightOverflow?.tileMatrix?.toTileRange()
with(overflowRight!!) {
assertEquals(2, visibleTiles.level)
assertEquals(0, colLeft)
assertEquals(3, colRight)
assertEquals(0, rowTop)
assertEquals(2, rowBottom)
}
phaseRight = visibleWindow.rightOverflow?.phase
assertEquals(mapOf(0 to 2, 1 to 1, 2 to 1, 3 to 1), phaseRight)
/* ------------------------------------------------------------------ */
scale = 0.5
viewport = Viewport(-256, 0, 512, 768)
visibleTiles = resolver.getVisibleTiles(viewport)
visibleWindow = visibleTiles.visibleWindow as VisibleWindow.InfiniteScrollX
overflowLeft = visibleWindow.leftOverflow?.tileMatrix?.toTileRange()
with(overflowLeft!!) {
assertEquals(1, visibleTiles.level)
assertEquals(1, colLeft)
assertEquals(1, colRight)
assertEquals(0, rowTop)
assertEquals(1, rowBottom)
}
phaseLeft = visibleWindow.leftOverflow?.phase
assertEquals(mapOf(1 to -1), phaseLeft)
overflowRight = visibleWindow.rightOverflow?.tileMatrix?.toTileRange()
assertTrue(overflowRight == null)
}
@Test
fun viewportTestSimple() {
val resolver = VisibleTilesResolver(
levelCount = 3,
fullWidth = 1000,
fullHeight = 800,
tileSize = 256,
magnifyingFactor = 0,
infiniteScrollX = false,
scaleProvider = scaleProvider
)
var viewport = Viewport(0, 0, 700, 512)
var visibleTiles = resolver.getVisibleTiles(viewport)
var tileMatrix = (visibleTiles.visibleWindow as VisibleWindow.BoundsConstrained).tileMatrix
with(tileMatrix.toTileRange()) {
assertEquals(2, visibleTiles.level)
assertEquals(0, colLeft)
assertEquals(0, rowTop)
assertEquals(2, colRight)
assertEquals(1, rowBottom)
}
scale = 0.5
viewport = Viewport(0, 0, 512, 512)
visibleTiles = resolver.getVisibleTiles(viewport)
tileMatrix = (visibleTiles.visibleWindow as VisibleWindow.BoundsConstrained).tileMatrix
with(tileMatrix.toTileRange()) {
assertEquals(1, visibleTiles.level)
assertEquals(0, colLeft)
assertEquals(0, rowTop)
assertEquals(1, colRight)
assertEquals(1, rowBottom)
}
scale = 1.0
val resolver2 = VisibleTilesResolver(
levelCount = 5,
fullWidth = 8192,
fullHeight = 8192,
tileSize = 256,
magnifyingFactor = 0,
infiniteScrollX = false,
scaleProvider = scaleProvider
)
val viewport2 = Viewport(0, 0, 8192, 8192)
visibleTiles = resolver2.getVisibleTiles(viewport2)
tileMatrix = (visibleTiles.visibleWindow as VisibleWindow.BoundsConstrained).tileMatrix
with(tileMatrix.toTileRange()) {
assertEquals(4, visibleTiles.level)
assertEquals(0, colLeft)
assertEquals(0, rowTop)
assertEquals(31, colRight)
assertEquals(31, rowBottom)
}
}
@Test
fun viewportTestAdvanced() {
// 6-level map.
// 256 * 2⁶ = 16384
scale = 1.0
val resolver = VisibleTilesResolver(
levelCount = 6,
fullWidth = 16400,
fullHeight = 8000,
tileSize = 256,
magnifyingFactor = 0,
infiniteScrollX = false,
scaleProvider = scaleProvider
)
var viewport = Viewport(0, 0, 1080, 1380)
var visibleTiles = resolver.getVisibleTiles(viewport)
var tileMatrix = (visibleTiles.visibleWindow as VisibleWindow.BoundsConstrained).tileMatrix
with(tileMatrix.toTileRange()) {
assertEquals(5, visibleTiles.level)
assertEquals(0, colLeft)
assertEquals(0, rowTop)
assertEquals(4, colRight)
assertEquals(5, rowBottom)
}
viewport = Viewport(4753, 6222, 4753 + 1080, 6222 + 1380)
visibleTiles = resolver.getVisibleTiles(viewport)
tileMatrix = (visibleTiles.visibleWindow as VisibleWindow.BoundsConstrained).tileMatrix
with(tileMatrix.toTileRange()) {
assertEquals(5, visibleTiles.level)
assertEquals(18, colLeft)
assertEquals(24, rowTop)
assertEquals(22, colRight)
assertEquals(29, rowBottom)
}
viewport = Viewport(3720, 1543, 3720 + 1080, 1543 + 1380)
scale = 0.5
visibleTiles = resolver.getVisibleTiles(viewport)
tileMatrix = (visibleTiles.visibleWindow as VisibleWindow.BoundsConstrained).tileMatrix
with(tileMatrix.toTileRange()) {
assertEquals(4, visibleTiles.level)
assertEquals(14, colLeft)
assertEquals(6, rowTop)
assertEquals(18, colRight)
assertEquals(11, rowBottom)
}
viewport = Viewport(3720, 1543, 3720 + 1080, 1543 + 1380)
scale = 0.71
visibleTiles = resolver.getVisibleTiles(viewport)
tileMatrix = (visibleTiles.visibleWindow as VisibleWindow.BoundsConstrained).tileMatrix
with(tileMatrix.toTileRange()) {
assertEquals(5, visibleTiles.level)
assertEquals(20, colLeft)
assertEquals(8, rowTop)
assertEquals(26, colRight)
assertEquals(16, rowBottom)
}
viewport = Viewport(1643, 427, 1643 + 1080, 427 + 1380)
scale = 0.43
visibleTiles = resolver.getVisibleTiles(viewport)
tileMatrix = (visibleTiles.visibleWindow as VisibleWindow.BoundsConstrained).tileMatrix
with(tileMatrix.toTileRange()) {
assertEquals(4, visibleTiles.level)
assertEquals(7, colLeft)
assertEquals(1, rowTop)
assertEquals(12, colRight)
assertEquals(8, rowBottom)
}
}
@Test
fun viewportMagnifyingTest() {
// 6-level map.
// 256 * 2⁶ = 16384
var resolver = VisibleTilesResolver(
levelCount = 6,
fullWidth = 16400,
fullHeight = 8000,
tileSize = 256, magnifyingFactor = 1,
infiniteScrollX = false,
scaleProvider = scaleProvider
)
scale = 0.37
var viewport = Viewport(3720, 1543, 3720 + 1080, 1543 + 1380)
var visibleTiles = resolver.getVisibleTiles(viewport)
var tileMatrix = (visibleTiles.visibleWindow as VisibleWindow.BoundsConstrained).tileMatrix
with(tileMatrix.toTileRange()) {
assertEquals(3, visibleTiles.level)
assertEquals(9, colLeft)
assertEquals(4, rowTop)
assertEquals(12, colRight)
assertEquals(7, rowBottom)
}
// magnify even further, with an abnormally big viewport
resolver = VisibleTilesResolver(
levelCount = 6,
fullWidth = 16400,
fullHeight = 8000,
tileSize = 256, magnifyingFactor = 2,
infiniteScrollX = false,
scaleProvider = scaleProvider
)
viewport = Viewport(250, 123, 250 + 1080, 123 + 1380)
scale = 0.37
visibleTiles = resolver.getVisibleTiles(viewport)
tileMatrix = (visibleTiles.visibleWindow as VisibleWindow.BoundsConstrained).tileMatrix
with(tileMatrix.toTileRange()) {
assertEquals(2, visibleTiles.level)
assertEquals(0, colLeft)
assertEquals(0, rowTop)
assertEquals(1, colRight)
assertEquals(1, rowBottom)
}
// (un)magnify
resolver = VisibleTilesResolver(
levelCount = 6,
fullWidth = 16400,
fullHeight = 8000,
tileSize = 256, magnifyingFactor = -1,
infiniteScrollX = false,
scaleProvider = scaleProvider
)
viewport = Viewport(3720, 1543, 3720 + 1080, 1543 + 1380)
scale = 0.37
visibleTiles = resolver.getVisibleTiles(viewport)
tileMatrix = (visibleTiles.visibleWindow as VisibleWindow.BoundsConstrained).tileMatrix
with(tileMatrix.toTileRange()) {
assertEquals(5, visibleTiles.level)
assertEquals(39, colLeft)
assertEquals(16, rowTop)
assertEquals(50, colRight)
assertEquals(30, rowBottom)
}
// Try to (un)magnify beyond available level: this shouldn't change anything
resolver = VisibleTilesResolver(
levelCount = 6,
fullWidth = 16400,
fullHeight = 8000,
tileSize = 256, magnifyingFactor = -2,
infiniteScrollX = false,
scaleProvider = scaleProvider
)
viewport = Viewport(3720, 1543, 3720 + 1080, 1543 + 1380)
scale = 0.37
visibleTiles = resolver.getVisibleTiles(viewport)
tileMatrix = (visibleTiles.visibleWindow as VisibleWindow.BoundsConstrained).tileMatrix
with(tileMatrix.toTileRange()) {
assertEquals(5, visibleTiles.level)
assertEquals(39, colLeft)
assertEquals(16, rowTop)
assertEquals(50, colRight)
assertEquals(30, rowBottom)
}
}
}
private data class TileRange(val colLeft: Int, val rowTop: Int, val colRight: Int, val rowBottom: Int)
/**
* If the tile matrix represents a rectangle, then is can be represented by a [TileRange].
* It only makes sense when the angle of rotation is 0 modulo pi/2
*/
private fun TileMatrix.toTileRange(): TileRange {
val rowTop = keys.minOrNull()!!
val rowBottom = keys.maxOrNull()!!
val colRange = getValue(rowTop)
val colLeft = colRange.first
val colRight = colRange.last
return TileRange(colLeft, rowTop, colRight, rowBottom)
}
================================================
FILE: mapcompose/src/test/java/ovh/plrapps/mapcompose/state/TileCanvasStateTest.kt
================================================
@file:OptIn(ExperimentalCoroutinesApi::class)
package ovh.plrapps.mapcompose.state
import android.os.Build
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import ovh.plrapps.mapcompose.core.Layer
import ovh.plrapps.mapcompose.core.TileStreamProvider
import ovh.plrapps.mapcompose.core.Viewport
import ovh.plrapps.mapcompose.core.VisibleTilesResolver
import ovh.plrapps.mapcompose.ui.state.TileCanvasState
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P])
class TileCanvasStateTest {
/**
* This test checks that the correct list of tiles is sent for rendering when `infiniteScrollX`
* is set to `true`.
*/
@Test
fun infiniteScrollTest() = runTest {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
Dispatchers.setMain(testDispatcher)
val scaleProvider = object : VisibleTilesResolver.ScaleProvider {
override fun getScale(): Double {
return 1.0
}
}
val tileCanvasState = TileCanvasState(
parentScope = backgroundScope,
visibleTilesResolver = VisibleTilesResolver(
levelCount = 4,
fullWidth = 1024,
fullHeight = 1024,
infiniteScrollX = true,
scaleProvider = scaleProvider
),
workerCount = 4,
tileSize = 256,
highFidelityColors = true
)
tileCanvasState.setLayers(
listOf(
Layer(
id = "id",
tileStreamProvider = TileStreamProvider { _, _, _ ->
return@TileStreamProvider null // actual bitmaps don't matter in this test
}
)
)
)
launch(Dispatchers.Default) {
/* Overflow on the left */
tileCanvasState.setViewport(Viewport(-1024, 0, 256, 512))
delay(500) // wait for tile production
assertEquals(8, tileCanvasState.tilesToRender.size)
var tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 0 && it.col == 0 }
assertEquals(-1..0, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 1 && it.col == 0 }
assertEquals(-1..0, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 0 && it.col == 1 }
assertEquals(-1..-1, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 1 && it.col == 1 }
assertEquals(-1..-1, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 0 && it.col == 2 }
assertEquals(-1..-1, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 1 && it.col == 3 }
assertEquals(-1..-1, tile?.phases)
/* Overflow on the right */
tileCanvasState.setViewport(Viewport(768, 0, 1024 + 1024 + 1024, 512))
delay(500) // wait for tile production
assertEquals(8, tileCanvasState.tilesToRender.size)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 0 && it.col == 3 }
assertEquals(0..2, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 1 && it.col == 3 }
assertEquals(0..2, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 0 && it.col == 1 }
assertEquals(1..2, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 1 && it.col == 1 }
assertEquals(1..2, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 0 && it.col == 2 }
assertEquals(1..2, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 1 && it.col == 3 }
assertEquals(0..2, tile?.phases)
/* Overflow on both left and right */
tileCanvasState.setViewport(Viewport(-1024, 0, 1024 + 1024 + 1024, 512))
delay(500) // wait for tile production
assertEquals(8, tileCanvasState.tilesToRender.size)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 0 && it.col == 3 }
assertEquals(-1..2, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 1 && it.col == 3 }
assertEquals(-1..2, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 0 && it.col == 1 }
assertEquals(-1..2, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 1 && it.col == 1 }
assertEquals(-1..2, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 0 && it.col == 2 }
assertEquals(-1..2, tile?.phases)
tile = tileCanvasState.tilesToRender.firstOrNull { it.row == 1 && it.col == 3 }
assertEquals(-1..2, tile?.phases)
}
}
}
================================================
FILE: mapcompose/src/test/java/ovh/plrapps/mapcompose/ui/paths/PathComposerTest.kt
================================================
package ovh.plrapps.mapcompose.ui.paths
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import junit.framework.TestCase.assertEquals
import org.junit.Test
import ovh.plrapps.mapcompose.ui.paths.model.PatternItem.*
class PathComposerTest {
private val density = object : Density {
override val density: Float = 1f
override val fontScale: Float = 1f
}
@Test
fun patternTest1() {
val pattern = listOf(Dot, Gap(15.dp))
val pathEffectData = makeIntervals(pattern, 10f, 1f, density)
assertEquals(listOf(1f, 25f), pathEffectData?.intervals?.toList())
assertEquals(0f, pathEffectData?.phase)
}
@Test
fun patternTest2() {
val pattern = listOf(Dot, Gap(15.dp), Dash(3.dp), Gap(15.dp))
val pathEffectData = makeIntervals(pattern, 10f, 1f, density)
assertEquals(listOf(1f, 25f, 3f, 25f), pathEffectData?.intervals?.toList())
assertEquals(0f, pathEffectData?.phase)
}
@Test
fun patternTest3() {
val pattern = listOf(Dot, Dot, Gap(15.dp), Dash(3.dp))
val pathEffectData = makeIntervals(pattern, 10f, 1f, density)
assertEquals(listOf(1f, 10f, 1f, 25f, 3f, 10f), pathEffectData?.intervals?.toList())
assertEquals(0f, pathEffectData?.phase)
}
@Test
fun `pattern with leading gap`() {
val pattern = listOf(Gap(25.dp), Dot, Dash(7.dp))
val pathEffectData = makeIntervals(pattern, 10f, 1f, density)
assertEquals(listOf(1f, 10f, 7f, 35f), pathEffectData?.intervals?.toList())
assertEquals(25f, pathEffectData?.phase)
}
@Test
fun `pattern with leading and trailing gap`() {
val pattern = listOf(Gap(25.dp), Dot, Gap(15.dp))
val pathEffectData = makeIntervals(pattern, 10f, 1f, density)
assertEquals(listOf(1f, 50f), pathEffectData?.intervals?.toList())
assertEquals(25f, pathEffectData?.phase)
}
@Test
fun `consecutive gaps should be concatenated`() {
val pattern = listOf(Dot, Gap(15.dp), Gap(4.dp), Dash(6.dp), Gap(9.dp))
val pathEffectData = makeIntervals(pattern, 10f, 1f, density)
assertEquals(listOf(1f, 29f, 6f, 19f), pathEffectData?.intervals?.toList())
assertEquals(0f, pathEffectData?.phase)
}
}
================================================
FILE: mapcompose/src/test/java/ovh/plrapps/mapcompose/utils/GeometryTest.kt
================================================
package ovh.plrapps.mapcompose.utils
import junit.framework.TestCase.assertEquals
import org.junit.Test
class GeometryTest {
@Test
fun getDistanceTest() {
val d = getDistance(1.0, 0.0, 0.0, 0.0, 0.0, 0.0)
assertEquals(1.0, d)
val d2 = getDistance(1.0, 0.0, 0.0, 0.0, 2.0, 0.0)
assertEquals(0.0, d2)
val d3 = getDistance(1.0, 1.0, 0.0, 0.0, 2.0, 0.0)
assertEquals(1.0, d3)
val d4 = getDistance(-1.0, 0.0, 0.0, 0.0, 2.0, 0.0)
assertEquals(1.0, d4)
val d5 = getDistance(3.0, 0.0, 0.0, 0.0, 2.0, 0.0)
assertEquals(1.0, d5)
}
}
================================================
FILE: mapcompose/src/test/java/ovh/plrapps/mapcompose/utils/VisibleAreaUtilsTest.kt
================================================
package ovh.plrapps.mapcompose.utils
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import org.junit.Test
import ovh.plrapps.mapcompose.api.VisibleArea
class VisibleAreaUtilsTest {
@Test
fun testVisibleAreaIntersect() {
val area1 = VisibleArea(
_p1x = 0.45080566406223405,
_p1y = 0.3736979166669377,
_p2x = 0.5572740261482944,
_p2y = 0.3736979166669377,
_p3x = 0.5572740261482944,
_p3y = 0.5914016629881235,
_p4x = 0.45080566406223405,
_p4y = 0.5914016629881235
)
val area2 = VisibleArea(
_p1x = 0.45167756735567244,
_p1y = 0.3721153620806136,
_p2x = 0.5485091051388183,
_p2y = 0.3721153620806136,
_p3x = 0.5485091051388183,
_p3y = 0.6169437501755156,
_p4x = 0.45167756735567244,
_p4y = 0.6169437501755156
)
assertTrue(area1.intersects(area2))
val area3 = area1.copy(
_p1x = area1._p1x + 0.1,
_p2x = area1._p2x + 0.1,
_p3x = area1._p3x + 0.1,
_p4x = area1._p4x + 0.1
)
assertTrue(area3.intersects(area1))
val area4 = area1.copy(
_p1x = area1._p1x + 0.1,
_p1y = area1._p1y + 0.1,
_p2x = area1._p2x + 0.1,
_p2y = area1._p2y + 0.1,
_p3x = area1._p3x + 0.1,
_p3y = area1._p3y + 0.1,
_p4x = area1._p4x + 0.1,
_p4y = area1._p4y + 0.1
)
assertTrue(area4.intersects(area1))
val area5 = area1.copy(
_p1x = area1._p1x + 0.1,
_p1y = area1._p1y + 0.1,
_p2x = area1._p2x - 0.1,
_p2y = area1._p2y + 0.1,
_p3x = area1._p3x - 0.1,
_p3y = area1._p3y - 0.1,
_p4x = area1._p4x + 0.1,
_p4y = area1._p4y - 0.1
)
assertTrue(area5.intersects(area1))
val area6 = area1.copy(
_p1x = area1._p1x + 1,
_p1y = area1._p1y + 1,
_p2x = area1._p2x + 1,
_p2y = area1._p2y + 1,
_p3x = area1._p3x + 1,
_p3y = area1._p3y + 1,
_p4x = area1._p4x + 1,
_p4y = area1._p4y + 1
)
assertFalse(area6.intersects(area1))
}
}
================================================
FILE: settings.gradle
================================================
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://dl.bintray.com/kotlin/kotlin-eap") }
}
}
rootProject.name = "MapCompose"
include ':mapcompose', ':demo', ':testapp'
================================================
FILE: testapp/.gitignore
================================================
/build
================================================
FILE: testapp/build.gradle
================================================
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id 'com.android.application'
id 'kotlin-android'
id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version"
}
android {
compileSdk = 36
defaultConfig {
applicationId "ovh.plrapps.mapcompose.testapp"
minSdk = 23
targetSdk = 36
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
namespace = 'ovh.plrapps.mapcompose.testapp'
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.18.0'
implementation 'androidx.appcompat:appcompat:1.7.1'
// Compose - See https://developer.android.com/jetpack/compose/setup#bom-version-mapping
implementation platform('androidx.compose:compose-bom:2026.04.01')
implementation "androidx.compose.ui:ui"
implementation "androidx.compose.material:material"
implementation "androidx.compose.material3:material3"
implementation "androidx.compose.ui:ui-tooling-preview"
debugImplementation "androidx.compose.ui:ui-tooling"
implementation 'androidx.navigation:navigation-compose:2.9.8'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.10.0'
implementation 'androidx.activity:activity-compose:1.13.0'
implementation project(':mapcompose')
testImplementation 'junit:junit:4.13.2'
}
================================================
FILE: testapp/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
================================================
FILE: testapp/src/main/AndroidManifest.xml
================================================
================================================
FILE: testapp/src/main/java/ovh/plrapps/mapcompose/testapp/MainActivity.kt
================================================
package ovh.plrapps.mapcompose.testapp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import ovh.plrapps.mapcompose.testapp.core.ui.MapComposeTestApp
import ovh.plrapps.mapcompose.testapp.core.ui.theme.MapComposeTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
MapComposeTheme {
MapComposeTestApp()
}
}
}
}
================================================
FILE: testapp/src/main/java/ovh/plrapps/mapcompose/testapp/core/ui/MapComposeTestApp.kt
================================================
package ovh.plrapps.mapcompose.testapp.core.ui
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import ovh.plrapps.mapcompose.testapp.core.ui.nav.HOME
import ovh.plrapps.mapcompose.testapp.core.ui.nav.NavDestinations
import ovh.plrapps.mapcompose.testapp.core.ui.theme.MapComposeTheme
import ovh.plrapps.mapcompose.testapp.features.clustering.MarkerClusteringUi
import ovh.plrapps.mapcompose.testapp.features.home.Home
import ovh.plrapps.mapcompose.testapp.features.layerswitch.LayerSwitchTest
@Composable
fun MapComposeTestApp() {
val navController = rememberNavController()
MapComposeTheme {
NavHost(navController, startDestination = HOME) {
composable(HOME) {
Home(demoListState = rememberLazyListState()) {
navController.navigate(it.name)
}
}
composable(NavDestinations.LAYERS_SWITCH.name) {
LayerSwitchTest()
}
composable(NavDestinations.CLUSTERING.name) {
MarkerClusteringUi()
}
}
}
}
================================================
FILE: testapp/src/main/java/ovh/plrapps/mapcompose/testapp/core/ui/nav/NavDestinations.kt
================================================
package ovh.plrapps.mapcompose.testapp.core.ui.nav
import androidx.annotation.StringRes
import ovh.plrapps.mapcompose.testapp.R
const val HOME = "home"
enum class NavDestinations(@StringRes val title: Int) {
LAYERS_SWITCH(R.string.layers_switch_test),
CLUSTERING(R.string.clustering_test)
}
================================================
FILE: testapp/src/main/java/ovh/plrapps/mapcompose/testapp/core/ui/theme/Color.kt
================================================
package ovh.plrapps.mapcompose.testapp.core.ui.theme
import androidx.compose.ui.graphics.Color
val primaryLight = Color(0xFF415F91)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFD6E3FF)
val onPrimaryContainerLight = Color(0xFF001B3E)
val secondaryLight = Color(0xFF565F71)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFDAE2F9)
val onSecondaryContainerLight = Color(0xFF131C2B)
val tertiaryLight = Color(0xFF705575)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFFFAD8FD)
val onTertiaryContainerLight = Color(0xFF28132E)
val errorLight = Color(0xFFBA1A1A)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF410002)
val backgroundLight = Color(0xFFF9F9FF)
val onBackgroundLight = Color(0xFF191C20)
val surfaceLight = Color(0xFFF9F9FF)
val onSurfaceLight = Color(0xFF191C20)
val surfaceVariantLight = Color(0xFFE0E2EC)
val onSurfaceVariantLight = Color(0xFF44474E)
val outlineLight = Color(0xFF74777F)
val outlineVariantLight = Color(0xFFC4C6D0)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF2E3036)
val inverseOnSurfaceLight = Color(0xFFF0F0F7)
val inversePrimaryLight = Color(0xFFAAC7FF)
val surfaceDimLight = Color(0xFFD9D9E0)
val surfaceBrightLight = Color(0xFFF9F9FF)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFF3F3FA)
val surfaceContainerLight = Color(0xFFEDEDF4)
val surfaceContainerHighLight = Color(0xFFE7E8EE)
val surfaceContainerHighestLight = Color(0xFFE2E2E9)
val primaryLightMediumContrast = Color(0xFF234373)
val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
val primaryContainerLightMediumContrast = Color(0xFF5875A8)
val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val secondaryLightMediumContrast = Color(0xFF3A4354)
val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
val secondaryContainerLightMediumContrast = Color(0xFF6C7588)
val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryLightMediumContrast = Color(0xFF523A58)
val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightMediumContrast = Color(0xFF876B8C)
val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val errorLightMediumContrast = Color(0xFF8C0009)
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
val errorContainerLightMediumContrast = Color(0xFFDA342E)
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
val backgroundLightMediumContrast = Color(0xFFF9F9FF)
val onBackgroundLightMediumContrast = Color(0xFF191C20)
val surfaceLightMediumContrast = Color(0xFFF9F9FF)
val onSurfaceLightMediumContrast = Color(0xFF191C20)
val surfaceVariantLightMediumContrast = Color(0xFFE0E2EC)
val onSurfaceVariantLightMediumContrast = Color(0xFF40434A)
val outlineLightMediumContrast = Color(0xFF5C5F67)
val outlineVariantLightMediumContrast = Color(0xFF787A83)
val scrimLightMediumContrast = Color(0xFF000000)
val inverseSurfaceLightMediumContrast = Color(0xFF2E3036)
val inverseOnSurfaceLightMediumContrast = Color(0xFFF0F0F7)
val inversePrimaryLightMediumContrast = Color(0xFFAAC7FF)
val surfaceDimLightMediumContrast = Color(0xFFD9D9E0)
val surfaceBrightLightMediumContrast = Color(0xFFF9F9FF)
val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightMediumContrast = Color(0xFFF3F3FA)
val surfaceContainerLightMediumContrast = Color(0xFFEDEDF4)
val surfaceContainerHighLightMediumContrast = Color(0xFFE7E8EE)
val surfaceContainerHighestLightMediumContrast = Color(0xFFE2E2E9)
val primaryLightHighContrast = Color(0xFF00214A)
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
val primaryContainerLightHighContrast = Color(0xFF234373)
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
val secondaryLightHighContrast = Color(0xFF192232)
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
val secondaryContainerLightHighContrast = Color(0xFF3A4354)
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
val tertiaryLightHighContrast = Color(0xFF301A35)
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightHighContrast = Color(0xFF523A58)
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
val errorLightHighContrast = Color(0xFF4E0002)
val onErrorLightHighContrast = Color(0xFFFFFFFF)
val errorContainerLightHighContrast = Color(0xFF8C0009)
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
val backgroundLightHighContrast = Color(0xFFF9F9FF)
val onBackgroundLightHighContrast = Color(0xFF191C20)
val surfaceLightHighContrast = Color(0xFFF9F9FF)
val onSurfaceLightHighContrast = Color(0xFF000000)
val surfaceVariantLightHighContrast = Color(0xFFE0E2EC)
val onSurfaceVariantLightHighContrast = Color(0xFF21242B)
val outlineLightHighContrast = Color(0xFF40434A)
val outlineVariantLightHighContrast = Color(0xFF40434A)
val scrimLightHighContrast = Color(0xFF000000)
val inverseSurfaceLightHighContrast = Color(0xFF2E3036)
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
val inversePrimaryLightHighContrast = Color(0xFFE5ECFF)
val surfaceDimLightHighContrast = Color(0xFFD9D9E0)
val surfaceBrightLightHighContrast = Color(0xFFF9F9FF)
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightHighContrast = Color(0xFFF3F3FA)
val surfaceContainerLightHighContrast = Color(0xFFEDEDF4)
val surfaceContainerHighLightHighContrast = Color(0xFFE7E8EE)
val surfaceContainerHighestLightHighContrast = Color(0xFFE2E2E9)
val primaryDark = Color(0xFFAAC7FF)
val onPrimaryDark = Color(0xFF0A305F)
val primaryContainerDark = Color(0xFF284777)
val onPrimaryContainerDark = Color(0xFFD6E3FF)
val secondaryDark = Color(0xFFBEC6DC)
val onSecondaryDark = Color(0xFF283141)
val secondaryContainerDark = Color(0xFF3E4759)
val onSecondaryContainerDark = Color(0xFFDAE2F9)
val tertiaryDark = Color(0xFFDDBCE0)
val onTertiaryDark = Color(0xFF3F2844)
val tertiaryContainerDark = Color(0xFF573E5C)
val onTertiaryContainerDark = Color(0xFFFAD8FD)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color(0xFF111318)
val onBackgroundDark = Color(0xFFE2E2E9)
val surfaceDark = Color(0xFF111318)
val onSurfaceDark = Color(0xFFE2E2E9)
val surfaceVariantDark = Color(0xFF44474E)
val onSurfaceVariantDark = Color(0xFFC4C6D0)
val outlineDark = Color(0xFF8E9099)
val outlineVariantDark = Color(0xFF44474E)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFE2E2E9)
val inverseOnSurfaceDark = Color(0xFF2E3036)
val inversePrimaryDark = Color(0xFF415F91)
val surfaceDimDark = Color(0xFF111318)
val surfaceBrightDark = Color(0xFF37393E)
val surfaceContainerLowestDark = Color(0xFF0C0E13)
val surfaceContainerLowDark = Color(0xFF191C20)
val surfaceContainerDark = Color(0xFF1D2024)
val surfaceContainerHighDark = Color(0xFF282A2F)
val surfaceContainerHighestDark = Color(0xFF33353A)
val primaryDarkMediumContrast = Color(0xFFB1CBFF)
val onPrimaryDarkMediumContrast = Color(0xFF001634)
val primaryContainerDarkMediumContrast = Color(0xFF7491C7)
val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
val secondaryDarkMediumContrast = Color(0xFFC2CBE0)
val onSecondaryDarkMediumContrast = Color(0xFF0D1626)
val secondaryContainerDarkMediumContrast = Color(0xFF8891A5)
val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
val tertiaryDarkMediumContrast = Color(0xFFE1C0E5)
val onTertiaryDarkMediumContrast = Color(0xFF230E29)
val tertiaryContainerDarkMediumContrast = Color(0xFFA487A9)
val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
val errorDarkMediumContrast = Color(0xFFFFBAB1)
val onErrorDarkMediumContrast = Color(0xFF370001)
val errorContainerDarkMediumContrast = Color(0xFFFF5449)
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
val backgroundDarkMediumContrast = Color(0xFF111318)
val onBackgroundDarkMediumContrast = Color(0xFFE2E2E9)
val surfaceDarkMediumContrast = Color(0xFF111318)
val onSurfaceDarkMediumContrast = Color(0xFFFBFAFF)
val surfaceVariantDarkMediumContrast = Color(0xFF44474E)
val onSurfaceVariantDarkMediumContrast = Color(0xFFC8CAD4)
val outlineDarkMediumContrast = Color(0xFFA0A3AC)
val outlineVariantDarkMediumContrast = Color(0xFF80838C)
val scrimDarkMediumContrast = Color(0xFF000000)
val inverseSurfaceDarkMediumContrast = Color(0xFFE2E2E9)
val inverseOnSurfaceDarkMediumContrast = Color(0xFF282A2F)
val inversePrimaryDarkMediumContrast = Color(0xFF294878)
val surfaceDimDarkMediumContrast = Color(0xFF111318)
val surfaceBrightDarkMediumContrast = Color(0xFF37393E)
val surfaceContainerLowestDarkMediumContrast = Color(0xFF0C0E13)
val surfaceContainerLowDarkMediumContrast = Color(0xFF191C20)
val surfaceContainerDarkMediumContrast = Color(0xFF1D2024)
val surfaceContainerHighDarkMediumContrast = Color(0xFF282A2F)
val surfaceContainerHighestDarkMediumContrast = Color(0xFF33353A)
val primaryDarkHighContrast = Color(0xFFFBFAFF)
val onPrimaryDarkHighContrast = Color(0xFF000000)
val primaryContainerDarkHighContrast = Color(0xFFB1CBFF)
val onPrimaryContainerDarkHighContrast = Color(0xFF000000)
val secondaryDarkHighContrast = Color(0xFFFBFAFF)
val onSecondaryDarkHighContrast = Color(0xFF000000)
val secondaryContainerDarkHighContrast = Color(0xFFC2CBE0)
val onSecondaryContainerDarkHighContrast = Color(0xFF000000)
val tertiaryDarkHighContrast = Color(0xFFFFF9FA)
val onTertiaryDarkHighContrast = Color(0xFF000000)
val tertiaryContainerDarkHighContrast = Color(0xFFE1C0E5)
val onTertiaryContainerDarkHighContrast = Color(0xFF000000)
val errorDarkHighContrast = Color(0xFFFFF9F9)
val onErrorDarkHighContrast = Color(0xFF000000)
val errorContainerDarkHighContrast = Color(0xFFFFBAB1)
val onErrorContainerDarkHighContrast = Color(0xFF000000)
val backgroundDarkHighContrast = Color(0xFF111318)
val onBackgroundDarkHighContrast = Color(0xFFE2E2E9)
val surfaceDarkHighContrast = Color(0xFF111318)
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkHighContrast = Color(0xFF44474E)
val onSurfaceVariantDarkHighContrast = Color(0xFFFBFAFF)
val outlineDarkHighContrast = Color(0xFFC8CAD4)
val outlineVariantDarkHighContrast = Color(0xFFC8CAD4)
val scrimDarkHighContrast = Color(0xFF000000)
val inverseSurfaceDarkHighContrast = Color(0xFFE2E2E9)
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
val inversePrimaryDarkHighContrast = Color(0xFF002959)
val surfaceDimDarkHighContrast = Color(0xFF111318)
val surfaceBrightDarkHighContrast = Color(0xFF37393E)
val surfaceContainerLowestDarkHighContrast = Color(0xFF0C0E13)
val surfaceContainerLowDarkHighContrast = Color(0xFF191C20)
val surfaceContainerDarkHighContrast = Color(0xFF1D2024)
val surfaceContainerHighDarkHighContrast = Color(0xFF282A2F)
val surfaceContainerHighestDarkHighContrast = Color(0xFF33353A)
================================================
FILE: testapp/src/main/java/ovh/plrapps/mapcompose/testapp/core/ui/theme/Theme.kt
================================================
package ovh.plrapps.mapcompose.testapp.core.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val lightScheme = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
outline = outlineLight,
outlineVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
surfaceDim = surfaceDimLight,
surfaceBright = surfaceBrightLight,
surfaceContainerLowest = surfaceContainerLowestLight,
surfaceContainerLow = surfaceContainerLowLight,
surfaceContainer = surfaceContainerLight,
surfaceContainerHigh = surfaceContainerHighLight,
surfaceContainerHighest = surfaceContainerHighestLight,
)
private val darkScheme = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
outline = outlineDark,
outlineVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
surfaceDim = surfaceDimDark,
surfaceBright = surfaceBrightDark,
surfaceContainerLowest = surfaceContainerLowestDark,
surfaceContainerLow = surfaceContainerLowDark,
surfaceContainer = surfaceContainerDark,
surfaceContainerHigh = surfaceContainerHighDark,
surfaceContainerHighest = surfaceContainerHighestDark,
)
private val mediumContrastLightColorScheme = lightColorScheme(
primary = primaryLightMediumContrast,
onPrimary = onPrimaryLightMediumContrast,
primaryContainer = primaryContainerLightMediumContrast,
onPrimaryContainer = onPrimaryContainerLightMediumContrast,
secondary = secondaryLightMediumContrast,
onSecondary = onSecondaryLightMediumContrast,
secondaryContainer = secondaryContainerLightMediumContrast,
onSecondaryContainer = onSecondaryContainerLightMediumContrast,
tertiary = tertiaryLightMediumContrast,
onTertiary = onTertiaryLightMediumContrast,
tertiaryContainer = tertiaryContainerLightMediumContrast,
onTertiaryContainer = onTertiaryContainerLightMediumContrast,
error = errorLightMediumContrast,
onError = onErrorLightMediumContrast,
errorContainer = errorContainerLightMediumContrast,
onErrorContainer = onErrorContainerLightMediumContrast,
background = backgroundLightMediumContrast,
onBackground = onBackgroundLightMediumContrast,
surface = surfaceLightMediumContrast,
onSurface = onSurfaceLightMediumContrast,
surfaceVariant = surfaceVariantLightMediumContrast,
onSurfaceVariant = onSurfaceVariantLightMediumContrast,
outline = outlineLightMediumContrast,
outlineVariant = outlineVariantLightMediumContrast,
scrim = scrimLightMediumContrast,
inverseSurface = inverseSurfaceLightMediumContrast,
inverseOnSurface = inverseOnSurfaceLightMediumContrast,
inversePrimary = inversePrimaryLightMediumContrast,
surfaceDim = surfaceDimLightMediumContrast,
surfaceBright = surfaceBrightLightMediumContrast,
surfaceContainerLowest = surfaceContainerLowestLightMediumContrast,
surfaceContainerLow = surfaceContainerLowLightMediumContrast,
surfaceContainer = surfaceContainerLightMediumContrast,
surfaceContainerHigh = surfaceContainerHighLightMediumContrast,
surfaceContainerHighest = surfaceContainerHighestLightMediumContrast,
)
private val highContrastLightColorScheme = lightColorScheme(
primary = primaryLightHighContrast,
onPrimary = onPrimaryLightHighContrast,
primaryContainer = primaryContainerLightHighContrast,
onPrimaryContainer = onPrimaryContainerLightHighContrast,
secondary = secondaryLightHighContrast,
onSecondary = onSecondaryLightHighContrast,
secondaryContainer = secondaryContainerLightHighContrast,
onSecondaryContainer = onSecondaryContainerLightHighContrast,
tertiary = tertiaryLightHighContrast,
onTertiary = onTertiaryLightHighContrast,
tertiaryContainer = tertiaryContainerLightHighContrast,
onTertiaryContainer = onTertiaryContainerLightHighContrast,
error = errorLightHighContrast,
onError = onErrorLightHighContrast,
errorContainer = errorContainerLightHighContrast,
onErrorContainer = onErrorContainerLightHighContrast,
background = backgroundLightHighContrast,
onBackground = onBackgroundLightHighContrast,
surface = surfaceLightHighContrast,
onSurface = onSurfaceLightHighContrast,
surfaceVariant = surfaceVariantLightHighContrast,
onSurfaceVariant = onSurfaceVariantLightHighContrast,
outline = outlineLightHighContrast,
outlineVariant = outlineVariantLightHighContrast,
scrim = scrimLightHighContrast,
inverseSurface = inverseSurfaceLightHighContrast,
inverseOnSurface = inverseOnSurfaceLightHighContrast,
inversePrimary = inversePrimaryLightHighContrast,
surfaceDim = surfaceDimLightHighContrast,
surfaceBright = surfaceBrightLightHighContrast,
surfaceContainerLowest = surfaceContainerLowestLightHighContrast,
surfaceContainerLow = surfaceContainerLowLightHighContrast,
surfaceContainer = surfaceContainerLightHighContrast,
surfaceContainerHigh = surfaceContainerHighLightHighContrast,
surfaceContainerHighest = surfaceContainerHighestLightHighContrast,
)
private val mediumContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkMediumContrast,
onPrimary = onPrimaryDarkMediumContrast,
primaryContainer = primaryContainerDarkMediumContrast,
onPrimaryContainer = onPrimaryContainerDarkMediumContrast,
secondary = secondaryDarkMediumContrast,
onSecondary = onSecondaryDarkMediumContrast,
secondaryContainer = secondaryContainerDarkMediumContrast,
onSecondaryContainer = onSecondaryContainerDarkMediumContrast,
tertiary = tertiaryDarkMediumContrast,
onTertiary = onTertiaryDarkMediumContrast,
tertiaryContainer = tertiaryContainerDarkMediumContrast,
onTertiaryContainer = onTertiaryContainerDarkMediumContrast,
error = errorDarkMediumContrast,
onError = onErrorDarkMediumContrast,
errorContainer = errorContainerDarkMediumContrast,
onErrorContainer = onErrorContainerDarkMediumContrast,
background = backgroundDarkMediumContrast,
onBackground = onBackgroundDarkMediumContrast,
surface = surfaceDarkMediumContrast,
onSurface = onSurfaceDarkMediumContrast,
surfaceVariant = surfaceVariantDarkMediumContrast,
onSurfaceVariant = onSurfaceVariantDarkMediumContrast,
outline = outlineDarkMediumContrast,
outlineVariant = outlineVariantDarkMediumContrast,
scrim = scrimDarkMediumContrast,
inverseSurface = inverseSurfaceDarkMediumContrast,
inverseOnSurface = inverseOnSurfaceDarkMediumContrast,
inversePrimary = inversePrimaryDarkMediumContrast,
surfaceDim = surfaceDimDarkMediumContrast,
surfaceBright = surfaceBrightDarkMediumContrast,
surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast,
surfaceContainerLow = surfaceContainerLowDarkMediumContrast,
surfaceContainer = surfaceContainerDarkMediumContrast,
surfaceContainerHigh = surfaceContainerHighDarkMediumContrast,
surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast,
)
private val highContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkHighContrast,
onPrimary = onPrimaryDarkHighContrast,
primaryContainer = primaryContainerDarkHighContrast,
onPrimaryContainer = onPrimaryContainerDarkHighContrast,
secondary = secondaryDarkHighContrast,
onSecondary = onSecondaryDarkHighContrast,
secondaryContainer = secondaryContainerDarkHighContrast,
onSecondaryContainer = onSecondaryContainerDarkHighContrast,
tertiary = tertiaryDarkHighContrast,
onTertiary = onTertiaryDarkHighContrast,
tertiaryContainer = tertiaryContainerDarkHighContrast,
onTertiaryContainer = onTertiaryContainerDarkHighContrast,
error = errorDarkHighContrast,
onError = onErrorDarkHighContrast,
errorContainer = errorContainerDarkHighContrast,
onErrorContainer = onErrorContainerDarkHighContrast,
background = backgroundDarkHighContrast,
onBackground = onBackgroundDarkHighContrast,
surface = surfaceDarkHighContrast,
onSurface = onSurfaceDarkHighContrast,
surfaceVariant = surfaceVariantDarkHighContrast,
onSurfaceVariant = onSurfaceVariantDarkHighContrast,
outline = outlineDarkHighContrast,
outlineVariant = outlineVariantDarkHighContrast,
scrim = scrimDarkHighContrast,
inverseSurface = inverseSurfaceDarkHighContrast,
inverseOnSurface = inverseOnSurfaceDarkHighContrast,
inversePrimary = inversePrimaryDarkHighContrast,
surfaceDim = surfaceDimDarkHighContrast,
surfaceBright = surfaceBrightDarkHighContrast,
surfaceContainerLowest = surfaceContainerLowestDarkHighContrast,
surfaceContainerLow = surfaceContainerLowDarkHighContrast,
surfaceContainer = surfaceContainerDarkHighContrast,
surfaceContainerHigh = surfaceContainerHighDarkHighContrast,
surfaceContainerHighest = surfaceContainerHighestDarkHighContrast,
)
@Immutable
data class ColorFamily(
val color: Color,
val onColor: Color,
val colorContainer: Color,
val onColorContainer: Color
)
val unspecified_scheme = ColorFamily(
Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified
)
@Composable
fun MapComposeTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable() () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkScheme
else -> lightScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}
================================================
FILE: testapp/src/main/java/ovh/plrapps/mapcompose/testapp/core/ui/theme/Type.kt
================================================
package ovh.plrapps.mapcompose.testapp.core.ui.theme
import androidx.compose.material3.Typography
val AppTypography = Typography()
================================================
FILE: testapp/src/main/java/ovh/plrapps/mapcompose/testapp/features/clustering/MarkerClusteringUi.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.testapp.features.clustering
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.testapp.core.ui.nav.NavDestinations
import ovh.plrapps.mapcompose.ui.MapUI
@Composable
fun MarkerClusteringUi(
viewModel: MarkersClusteringViewModel = viewModel()
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(NavDestinations.CLUSTERING.title)) },
)
}
) { padding ->
MapUI(Modifier.padding(padding), state = viewModel.state)
}
}
================================================
FILE: testapp/src/main/java/ovh/plrapps/mapcompose/testapp/features/clustering/MarkersClusteringViewModel.kt
================================================
package ovh.plrapps.mapcompose.testapp.features.clustering
import android.app.Application
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import ovh.plrapps.mapcompose.api.addClusterer
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.api.enableRotation
import ovh.plrapps.mapcompose.api.onMarkerClick
import ovh.plrapps.mapcompose.api.shouldLoopScale
import ovh.plrapps.mapcompose.core.TileStreamProvider
import ovh.plrapps.mapcompose.testapp.R
import ovh.plrapps.mapcompose.testapp.utils.randomDouble
import ovh.plrapps.mapcompose.ui.state.MapState
import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy
import kotlin.random.Random.Default.nextDouble
/**
* In this sample, an experimental clustering algorithm is used to display 400 markers.
* The lazy loading technique (removing a marker/cluster when it's not visible) is also used for
* performance reasons.
*/
class MarkersClusteringViewModel(application: Application) : AndroidViewModel(application) {
private val tileStreamProvider = makeTileStreamProvider(application.applicationContext)
private fun makeTileStreamProvider(appContext: Context): TileStreamProvider {
return TileStreamProvider { row, col, level ->
runCatching {
appContext.assets?.open("tiles/mont_blanc/$level/$row/$col.jpg")
}.getOrNull()
}
}
val state: MapState = MapState(4, 4096, 4096) {
scale(0.81)
maxScale(8.0)
}.apply {
addLayer(tileStreamProvider)
enableRotation()
shouldLoopScale = true
onMarkerClick { id, x, y ->
println("on marker click $id $x $y")
}
}
init {
state.addClusterer("default") { n ->
{
/* Here we can customize the cluster style */
Box(
modifier = Modifier
.background(
Color(0x992196F3),
shape = CircleShape
)
.size(50.dp),
contentAlignment = Alignment.Center
) {
Text(text = n.size.toString(), color = Color.White)
}
}
}
repeat(40) { i ->
val cx = nextDouble()
val cy = nextDouble()
repeat(10) { j ->
val x = randomDouble(cx, 0.03).coerceAtLeast(0.0)
val y = randomDouble(cy, 0.03).coerceAtLeast(0.0)
/* Notice how we set the cluster which we previously added */
state.addMarker(
"marker-$i-$j", x, y,
renderingStrategy = RenderingStrategy.Clustering("default")
) {
Icon(
painter = painterResource(id = R.drawable.map_marker),
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = Color(0xEE2196F3)
)
}
}
}
}
}
================================================
FILE: testapp/src/main/java/ovh/plrapps/mapcompose/testapp/features/home/Home.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.testapp.features.home
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import ovh.plrapps.mapcompose.testapp.R
import ovh.plrapps.mapcompose.testapp.core.ui.nav.NavDestinations
@Composable
fun Home(demoListState: LazyListState, onDemoSelected: (dest: NavDestinations) -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
)
}
) { padding ->
LazyColumn(
Modifier.padding(padding),
state = demoListState
) {
NavDestinations.values().map { dest ->
item {
Text(
text = stringResource(dest.title),
modifier = Modifier
.fillMaxWidth()
.clickable { onDemoSelected.invoke(dest) }
.padding(16.dp),
textAlign = TextAlign.Center
)
HorizontalDivider(thickness = 1.dp)
}
}
}
}
}
================================================
FILE: testapp/src/main/java/ovh/plrapps/mapcompose/testapp/features/layerswitch/LayerSwitchTest.kt
================================================
@file:OptIn(ExperimentalMaterial3Api::class)
package ovh.plrapps.mapcompose.testapp.features.layerswitch
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import ovh.plrapps.mapcompose.testapp.core.ui.nav.NavDestinations
import ovh.plrapps.mapcompose.ui.MapUI
@Composable
fun LayerSwitchTest(viewModel: LayerSwitchViewModel = viewModel()) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(NavDestinations.LAYERS_SWITCH.title)) },
)
}
) { padding ->
MapUI(Modifier.padding(padding), state = viewModel.state)
}
}
================================================
FILE: testapp/src/main/java/ovh/plrapps/mapcompose/testapp/features/layerswitch/LayerSwitchViewModel.kt
================================================
package ovh.plrapps.mapcompose.testapp.features.layerswitch
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.enableRotation
import ovh.plrapps.mapcompose.api.replaceLayer
import ovh.plrapps.mapcompose.api.scrollTo
import ovh.plrapps.mapcompose.api.shouldLoopScale
import ovh.plrapps.mapcompose.core.TileStreamProvider
import ovh.plrapps.mapcompose.ui.state.MapState
class LayerSwitchViewModel(application: Application) : AndroidViewModel(application) {
private val appContext: Context by lazy {
getApplication().applicationContext
}
private var type = 0
private val tileStreamProvider = makeTileStreamProvider(appContext, type)
private var currentLayerId: String? = null
val state: MapState = MapState(4, 4096, 4096, workerCount = 64).apply {
shouldLoopScale = true
enableRotation()
viewModelScope.launch {
scrollTo(0.5, 0.5, 1.0)
}
currentLayerId = addLayer(tileStreamProvider)
}
init {
viewModelScope.launch {
while (true) {
delay(2000)
changeMapType()
delay(200)
changeMapType()
}
}
}
private fun changeMapType() {
type = ((0..2) - type).random()
val tileStreamProvider = makeTileStreamProvider(appContext, type)
currentLayerId?.also { id ->
currentLayerId = state.replaceLayer(id, tileStreamProvider)
}
}
private fun makeTileStreamProvider(appContext: Context, type: Int): TileStreamProvider {
/* Pay attention to how type is captured and immutable in the context of the TileStreamProvider */
return TileStreamProvider { row, col, _ ->
runCatching {
Thread.sleep((100L..200L).random())
appContext.assets?.open("tiles/test/tile_${type}_${col}_$row.png")
}.getOrNull()
}
}
}
================================================
FILE: testapp/src/main/java/ovh/plrapps/mapcompose/testapp/utils/Random.kt
================================================
package ovh.plrapps.mapcompose.testapp.utils
import kotlin.random.Random.Default.nextDouble
fun randomDouble(center: Double, radius: Double) : Double {
return nextDouble(from = center - radius, until = center + radius)
}
================================================
FILE: testapp/src/main/res/drawable/ic_launcher_background.xml
================================================
================================================
FILE: testapp/src/main/res/drawable/map_marker.xml
================================================
================================================
FILE: testapp/src/main/res/drawable-v24/ic_launcher_foreground.xml
================================================
================================================
FILE: testapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
================================================
================================================
FILE: testapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
================================================
================================================
FILE: testapp/src/main/res/values/colors.xml
================================================
#FFBB86FC
#FF6200EE
#FF3700B3
#FF03DAC5
#FF018786
#FF000000
#FFFFFFFF
================================================
FILE: testapp/src/main/res/values/strings.xml
================================================
MapCompose Test App
Layers switch stress test
Clustering stress test
================================================
FILE: testapp/src/main/res/values/themes.xml
================================================
================================================
FILE: testapp/src/test/java/ovh/plrapps/mapcompose/testapp/ExampleUnitTest.kt
================================================
package ovh.plrapps.mapcompose.testapp
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}