Compare commits
231
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f55befe9f1 | ||
|
|
64e0d5de03
|
||
|
|
2cf32128ba
|
||
|
|
2b900afc30
|
||
|
|
d059d031bd
|
||
|
|
caff1a07cc
|
||
|
|
fe766c8820
|
||
|
|
1e6a374e96
|
||
|
|
02b92308d0
|
||
|
|
a9c8a89aac | ||
|
|
a69d5644a5 | ||
|
|
eab348d187 | ||
|
|
1877455c70 | ||
|
|
0b6074aaba | ||
|
|
875d809189 | ||
|
|
3960f5cb5d | ||
|
|
07368a408c | ||
|
|
844da3ac93 | ||
|
|
af27e53150 | ||
|
|
9bced78550 | ||
|
|
0129758f74 | ||
|
|
97657eeb0d | ||
|
|
99db62cada | ||
|
|
606ffe2aff | ||
|
|
d3cf4d69d6 | ||
|
|
1e4e24eef8 | ||
|
|
3036ffcc76 | ||
|
|
1a98af2844 | ||
|
|
7ecd655a53 | ||
|
|
d7f8939750 | ||
|
|
e3be95449e | ||
|
|
61a8ee5439 | ||
|
|
f0da771089 | ||
|
|
d39a750597 | ||
|
|
e32715554c | ||
|
|
731f84bf58 | ||
|
|
1c3d2b62c6 | ||
|
|
66a8b9a1a9 | ||
|
|
c165eacdeb | ||
|
|
36cfb7dc05 | ||
|
|
3ad9dfe8f4 | ||
|
|
82065515e6 | ||
|
|
74b278e972 | ||
|
|
f9ac6c35e5 | ||
|
|
975c60df63 | ||
|
|
8fed759a8a | ||
|
|
ca4f4b0833 | ||
|
|
68f9501e3b | ||
|
|
76efae59cc | ||
|
|
48c7516a66 | ||
|
|
cee26154b8 | ||
|
|
48eba6c069 | ||
|
|
ab599e7189 | ||
|
|
8b498bdc99 | ||
|
|
52b589959f | ||
|
|
7ce8a93c9f | ||
|
|
49a9bad969 | ||
|
|
92c41a264d | ||
|
|
9bdc5c9bdc | ||
|
|
f2047a62f4 | ||
|
|
af8af0db31 | ||
|
|
3d312b7f6d | ||
|
|
788686fd59 | ||
|
|
e1368b9e16 | ||
|
|
3900e74953 | ||
|
|
42f9b66ed0 | ||
|
|
fb162ff48f | ||
|
|
7c8b2f0f7d | ||
|
|
c8f5e4d8e1 | ||
|
|
a227f86d36 | ||
|
|
e3c84a795c | ||
|
|
32b62b5a22 | ||
|
|
6f747d9f85 | ||
|
|
7de915832d | ||
|
|
725a7d6282 | ||
|
|
65c0ed5d7a | ||
|
|
ed16b7dd21 | ||
|
|
2e425ad97f | ||
|
|
1399b98a73 | ||
|
|
f404c70998 | ||
|
|
91bbfc2db8 | ||
|
|
59d51fc35f | ||
|
|
fd5e163055 | ||
|
|
5820e164b0 | ||
|
|
3a4f8b58ff | ||
|
|
aa83c53db7 | ||
|
|
ceced8bfe5 | ||
|
|
b23f9f774c | ||
|
|
572bdaf9a7 | ||
|
|
a481d20f8e | ||
|
|
74812d5528 | ||
|
|
3e3c028fb8 | ||
|
|
8373aec5a4 | ||
|
|
618e9693ae | ||
|
|
3b2b18713f | ||
|
|
66d035546d | ||
|
|
d725eeea7f | ||
|
|
0ceaa500e5 | ||
|
|
6fc3b37076 | ||
|
|
90e7805363 | ||
|
|
de9b372543 | ||
|
|
0fa80e015b | ||
|
|
a1e55680e6 | ||
|
|
2ea4481422 | ||
|
|
d0226d9bc8 | ||
|
|
3ca6f59e0b | ||
|
|
21841da60d | ||
|
|
c78df673a8 | ||
|
|
7bec552612 | ||
|
|
a2d2a8edd3 | ||
|
|
0876ae5955 | ||
|
|
ab9e8f5063 | ||
|
|
864fe26c95 | ||
|
|
79ebc617a4 | ||
|
|
ff84cce5ec | ||
|
|
953ce75b26 | ||
|
|
7eb68a1436 | ||
|
|
ed6b4f99ee | ||
|
|
c29590c51e | ||
|
|
544ffa82fd | ||
|
|
133f0992f9 | ||
|
|
deb2fd536f | ||
|
|
46586dc2e4 | ||
|
|
b255ef907a | ||
|
|
cf9b1cc6e2
|
||
|
|
d78ef0b38b
|
||
|
|
30eb5657ce
|
||
|
|
4085149e88
|
||
|
|
1788d734d2 | ||
|
|
b53215e4ba | ||
|
|
df19f9640f | ||
|
|
31aa9630d0 | ||
|
|
b1019aeb0b | ||
|
|
63aa14e31a | ||
|
|
2d87507fa4
|
||
|
|
bb8dc15554 | ||
|
|
b3369f6390 | ||
|
|
46df3b5dbe | ||
|
|
3ab93c7342 | ||
|
|
a8c4529ee8 | ||
|
|
e7a3f09547 | ||
|
|
579cf75ca2 | ||
|
|
121b34c034 | ||
|
|
97014e9454 | ||
|
|
674106923f | ||
|
|
e21dad8ed7 | ||
|
|
b2cca2332b | ||
|
|
13ad82df95 | ||
|
|
2befea2325 | ||
|
|
f45cc57049 | ||
|
|
a7f05d5d80 | ||
|
|
269e844a4c | ||
|
|
818fc49e3d | ||
|
|
8a3f7f7621 | ||
|
|
8c50670a9d | ||
|
|
141ea67d2e | ||
|
|
291fae7aec
|
||
|
|
7a04440eca | ||
|
|
691d2e4ea1 | ||
|
|
dd5c9387f9 | ||
|
|
6038d132ec | ||
|
|
6a0ac3e5c7 | ||
|
|
327a6550b4 | ||
|
|
7027d0ffb1 | ||
|
|
15b33b59ed | ||
|
|
36787717ff | ||
|
|
c27aef23c0 | ||
|
|
a9e567d299 | ||
|
|
468fb4ab13 | ||
|
|
e952de3c17 | ||
|
|
da1793bc21 | ||
|
|
650a59b1ac | ||
|
|
5a9284749c | ||
|
|
981fc5ebf4 | ||
|
|
78f5fe7c35 | ||
|
|
bdf2911a1c | ||
|
|
40b2040f25 | ||
|
|
48c88d85a4 | ||
|
|
727c828128 | ||
|
|
cce6719158 | ||
|
|
13a79baa2b | ||
|
|
c956fb9876 | ||
|
|
339ac468f3 | ||
|
|
f1eacee394 | ||
|
|
a000fb4a3d | ||
|
|
16ff2e1bc8 | ||
|
|
6ee16f02ab | ||
|
|
fd05d9b6f5 | ||
|
|
eece41d0ba | ||
|
|
b609704cc1 | ||
|
|
39436f473a | ||
|
|
ce2721025a | ||
|
|
371a2aa35f | ||
|
|
0f986616ae | ||
|
|
eb948c2e59 | ||
|
|
4fe43c5fc6 | ||
|
|
eed23b35d4 | ||
|
|
e9d46d0824 | ||
|
|
ccd18f3717 | ||
|
|
1e78f269c1 | ||
|
|
0740c00014 | ||
|
|
740526104b | ||
|
|
f807fa62d2 | ||
|
|
18725261e3 | ||
|
|
cc1a0c1aca
|
||
|
|
f2a5efcec5
|
||
|
|
cfa784991b
|
||
|
|
14f8543d3d
|
||
|
|
5705749d12
|
||
|
|
1d0b814e77 | ||
|
|
ecc26f811a | ||
|
|
8cd93d866b | ||
|
|
6bf4034e1e | ||
|
|
977321ddb5 | ||
|
|
bee96b5d9b | ||
|
|
6d21f0da6d | ||
|
|
445ccbcac0 | ||
|
|
225e5bfdc8 | ||
|
|
0c446f0d59 | ||
|
|
7c0b9bda80 | ||
|
|
eaa0278764 | ||
|
|
eeac01f638 | ||
|
|
9f8d5aaa18 | ||
|
|
cdb9d51257 | ||
|
|
2d6b895f57 | ||
|
|
80e191a50d | ||
|
|
4114185a73 | ||
|
|
24fe56c02a | ||
|
|
d83234cf5b | ||
|
|
e608b22c0c | ||
|
|
f8ed30cd73
|
@@ -23,7 +23,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
run: git checkout -B "$BRANCH"
|
run: git checkout -B "$BRANCH"
|
||||||
|
|
||||||
- name: set up JDK
|
- name: set up JDK
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
java-version: 21
|
java-version: 21
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
@@ -50,10 +50,27 @@ jobs:
|
|||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v6
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Build APK
|
- name: Build APK
|
||||||
run: |
|
run: |
|
||||||
VERSION_CODE=$(git rev-list --count HEAD)
|
VERSION_CODE=$(git rev-list --count HEAD)
|
||||||
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE
|
VERSION_BASE=$(git describe --tags --abbrev=0 | sed 's/^v//')
|
||||||
|
VERSION_DEV=$(git rev-list --count $(git describe --tags --abbrev=0)..HEAD)
|
||||||
|
if [ $VERSION_DEV -gt 0 ]; then
|
||||||
|
VERSION_NAME="${VERSION_BASE}.${VERSION_DEV}"
|
||||||
|
else
|
||||||
|
VERSION_NAME="${VERSION_BASE}"
|
||||||
|
fi
|
||||||
|
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
|
||||||
|
|
||||||
|
- name: Upload APK
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
path: app/build/outputs/apk/signedRelease/app-signedRelease.apk
|
||||||
|
compression-level: 0
|
||||||
|
archive: false
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
|
|||||||
+3
-1
@@ -22,4 +22,6 @@ app/release/
|
|||||||
captures/
|
captures/
|
||||||
local.properties
|
local.properties
|
||||||
keystore.properties
|
keystore.properties
|
||||||
key.jks
|
key.jks
|
||||||
|
# generated map intermediates; the packed .bmap in assets is what ships
|
||||||
|
mapdata/*.geojson
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Thanks to all contributors, the developers of our dependencies, and our users.
|
|||||||
## 📝 License
|
## 📝 License
|
||||||
|
|
||||||
```
|
```
|
||||||
Copyright (C) 2024 Helcel & MYDOLI
|
Copyright (C) 2026 Helcel & MYDOLI
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU General Public License as published by
|
||||||
|
|||||||
+19
-23
@@ -1,8 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.21'
|
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.10'
|
||||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.3.21'
|
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.10'
|
||||||
id 'com.mikepenz.aboutlibraries.plugin' version '14.2.0'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -15,7 +14,7 @@ android {
|
|||||||
applicationId 'net.helcel.beans'
|
applicationId 'net.helcel.beans'
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionName "1.3"
|
versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.5"
|
||||||
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
|
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -74,42 +73,39 @@ android {
|
|||||||
kotlinCompilerExtensionVersion = "2.2.20"
|
kotlinCompilerExtensionVersion = "2.2.20"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
androidResources {
|
||||||
|
// The app ships English strings only; without this the libraries drag
|
||||||
|
// in some eighty locales of their own.
|
||||||
|
localeFilters += ["en"]
|
||||||
|
}
|
||||||
|
|
||||||
lint {
|
lint {
|
||||||
disable 'UsingMaterialAndMaterial3Libraries'
|
disable 'UsingMaterialAndMaterial3Libraries'
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
aboutLibraries {
|
|
||||||
library {
|
|
||||||
exclusionPatterns = [~"androidx.*", ~"com.google.android.*", ~"org.jetbrains.*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'androidx.compose.material3:material3:1.4.0'
|
implementation 'androidx.compose.material3:material3:1.4.0'
|
||||||
implementation "androidx.compose.material:material:1.11.2"
|
implementation "androidx.compose.material:material:1.12.0"
|
||||||
implementation 'androidx.compose.material:material-icons-extended:1.7.8'
|
implementation 'androidx.compose.material:material-icons-extended:1.7.8'
|
||||||
implementation 'androidx.navigation:navigation-compose:2.9.8'
|
implementation 'androidx.navigation:navigation-compose:2.10.0'
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'
|
||||||
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0'
|
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0'
|
||||||
|
|
||||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
|
||||||
implementation 'androidx.compose.ui:ui'
|
implementation 'androidx.compose.ui:ui'
|
||||||
implementation "androidx.activity:activity-ktx:1.13.0"
|
implementation "androidx.activity:activity-ktx:1.13.0"
|
||||||
|
|
||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
implementation 'com.google.android.material:material:1.14.0'
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0'
|
|
||||||
|
|
||||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
|
||||||
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
|
|
||||||
|
|
||||||
implementation 'com.mikepenz:aboutlibraries:14.2.1'
|
|
||||||
implementation 'com.mikepenz:aboutlibraries-compose-m3:14.2.0'
|
|
||||||
implementation 'com.mikepenz:aboutlibraries-core:14.2.0'
|
|
||||||
|
|
||||||
|
|
||||||
implementation platform('androidx.compose:compose-bom:2026.05.01')
|
implementation platform('androidx.compose:compose-bom:2026.08.00')
|
||||||
debugImplementation 'androidx.compose.ui:ui-tooling:1.11.2'
|
debugImplementation 'androidx.compose.ui:ui-tooling:1.12.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.configureEach { task ->
|
||||||
|
if (task.name.startsWith("merge") && task.name.endsWith("Assets")) {
|
||||||
|
task.outputs.upToDateWhen { false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.1 MiB |
+3104
-3102
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.1 MiB |
@@ -1,14 +1,16 @@
|
|||||||
package net.helcel.beans.activity
|
package net.helcel.beans.activity
|
||||||
|
|
||||||
import android.graphics.drawable.PictureDrawable
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.ImageView
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
import androidx.compose.material.IconButton
|
import androidx.compose.material.IconButton
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
@@ -20,58 +22,82 @@ import androidx.compose.material.icons.filled.Edit
|
|||||||
import androidx.compose.material.icons.filled.Percent
|
import androidx.compose.material.icons.filled.Percent
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.caverock.androidsvg.RenderOptions
|
|
||||||
import com.github.chrisbanes.photoview.PhotoView
|
|
||||||
import net.helcel.beans.BuildConfig
|
import net.helcel.beans.BuildConfig
|
||||||
|
import net.helcel.beans.activity.sub.EditPlaceDialog
|
||||||
|
import net.helcel.beans.activity.sub.MapPickDialog
|
||||||
|
import net.helcel.beans.activity.sub.applyDirectVisit
|
||||||
|
import net.helcel.beans.activity.sub.commitVisitDialog
|
||||||
|
import net.helcel.beans.countries.GeoLoc
|
||||||
import net.helcel.beans.countries.GeoLocImporter
|
import net.helcel.beans.countries.GeoLocImporter
|
||||||
|
import net.helcel.beans.countries.GeoLocTree
|
||||||
import net.helcel.beans.helper.Data
|
import net.helcel.beans.helper.Data
|
||||||
import net.helcel.beans.helper.Settings
|
import net.helcel.beans.helper.Settings
|
||||||
import net.helcel.beans.svg.CSSWrapper
|
import net.helcel.beans.map.MapAssets
|
||||||
import net.helcel.beans.svg.SVGWrapper
|
import net.helcel.beans.map.MapStyle
|
||||||
|
import net.helcel.beans.map.MapView
|
||||||
|
import net.helcel.beans.map.MapWorld
|
||||||
|
import net.helcel.beans.map.MapReader
|
||||||
|
|
||||||
|
|
||||||
class MainScreen : ComponentActivity() {
|
class MainScreen : ComponentActivity() {
|
||||||
|
|
||||||
private lateinit var psvg: SVGWrapper
|
private var world by mutableStateOf<MapWorld?>(null)
|
||||||
private lateinit var css: CSSWrapper
|
private var loadToken = 0
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
actionBar?.hide()
|
actionBar?.hide()
|
||||||
Settings.start(this)
|
Settings.start(this)
|
||||||
GeoLocImporter.importStates(this)
|
|
||||||
Data.loadData(this, Int.MIN_VALUE)
|
Data.loadData(this, Int.MIN_VALUE)
|
||||||
|
GeoLocImporter.importStates(this)
|
||||||
|
|
||||||
|
refreshProjection()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SysTheme {
|
SysTheme {
|
||||||
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background)) {
|
// Both bars: without the navigation inset the system buttons sit on top of
|
||||||
AppNavHost(psvg, css)
|
// whatever is at the bottom of a screen, such as the About button.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colors.primary)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
) {
|
||||||
|
AppNavHost()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshProjection()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavHost(psvg: SVGWrapper, css: CSSWrapper) {
|
fun AppNavHost() {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
NavHost(navController, startDestination = "main") {
|
NavHost(navController, startDestination = "main") {
|
||||||
composable("main") { MainScreenC(psvg,css, navController) }
|
composable("main") { MainScreenC(world, navController) }
|
||||||
composable("settings") { SettingsMainScreen { navController.navigate("main")} }
|
composable("settings") { SettingsMainScreen { navController.navigate("main") } }
|
||||||
composable("edit") { EditScreen { navController.navigate("main") } }
|
composable("edit") { EditScreen { navController.navigate("main") } }
|
||||||
composable("stats") { StatsScreen { navController.navigate("main") } }
|
composable("stats") { StatsScreen { navController.navigate("main") } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreenC(psvg: SVGWrapper,css: CSSWrapper, nav: NavHostController){
|
fun MainScreenC(world: MapWorld?, nav: NavHostController){
|
||||||
SysTheme {
|
SysTheme {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -91,33 +117,87 @@ class MainScreen : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Box(modifier = Modifier.padding(innerPadding)) {
|
Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
|
||||||
MapScreen(psvg, css)
|
if (world == null) LoadingMap() else MapScreen(world)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MapScreen(psvg: SVGWrapper, css: CSSWrapper) {
|
fun LoadingMap() {
|
||||||
Box {
|
Box(
|
||||||
val opt: RenderOptions = RenderOptions.create()
|
modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background),
|
||||||
opt.css(css.get())
|
contentAlignment = Alignment.Center,
|
||||||
val drawable = remember(psvg, css) {
|
) {
|
||||||
PictureDrawable(psvg.get()?.renderToPicture(opt))
|
CircularProgressIndicator(
|
||||||
}
|
color = MaterialTheme.colors.primary,
|
||||||
AndroidView(factory = { ctx ->
|
strokeWidth = 4.dp,
|
||||||
PhotoView(ctx).apply {
|
modifier = Modifier.size(50.dp),
|
||||||
setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null)
|
)
|
||||||
setImageDrawable(drawable)
|
|
||||||
scaleType = ImageView.ScaleType.FIT_CENTER
|
|
||||||
}
|
|
||||||
}, modifier = Modifier.fillMaxSize())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MapScreen(world: MapWorld) {
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
val visits by Data.visits.visitsFlow.collectAsState()
|
||||||
|
val groups by Data.groups.groupsFlow.collectAsState()
|
||||||
|
val land = MaterialTheme.colors.onBackground.toArgb()
|
||||||
|
val background = MaterialTheme.colors.background.toArgb()
|
||||||
|
val style = remember(visits, groups, land, background) {
|
||||||
|
MapStyle.build(ctx, land, background)
|
||||||
|
}
|
||||||
|
val touchRadius = Settings.getTouchRadius(ctx)
|
||||||
|
|
||||||
|
var candidates by remember { mutableStateOf<List<GeoLoc>>(emptyList()) }
|
||||||
|
var showColor by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// A place is either applied straight away, or the colour dialog picks
|
||||||
|
// the group for it, exactly as it does from the edit list.
|
||||||
|
fun select(loc: GeoLoc) {
|
||||||
|
if (!applyDirectVisit(ctx, loc)) showColor = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.isNotEmpty()) {
|
||||||
|
MapPickDialog(
|
||||||
|
candidates = candidates,
|
||||||
|
onPick = { candidates = emptyList(); select(it) },
|
||||||
|
onDismiss = { candidates = emptyList() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showColor) {
|
||||||
|
EditPlaceDialog(false) { cleared ->
|
||||||
|
showColor = false
|
||||||
|
commitVisitDialog(cleared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
factory = { MapView(it) },
|
||||||
|
update = { view ->
|
||||||
|
view.world = world
|
||||||
|
view.style = style
|
||||||
|
view.touchRadiusDp = touchRadius
|
||||||
|
// Always the tree, even for a single hit: landing on a region is
|
||||||
|
// just as often a way of reaching the country around it.
|
||||||
|
view.onPick = { picks ->
|
||||||
|
val locs = picks.mapNotNull { GeoLocTree.find(it.code) }
|
||||||
|
if (locs.isNotEmpty()) candidates = locs
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reloads the map asset, on a worker thread since it is a few megabytes. */
|
||||||
fun refreshProjection() {
|
fun refreshProjection() {
|
||||||
psvg = SVGWrapper(this)
|
val asset = MapAssets.assetFor(this)
|
||||||
css = CSSWrapper(this)
|
val token = ++loadToken
|
||||||
|
world = null
|
||||||
|
Thread {
|
||||||
|
val parsed = assets.open(asset).use { MapReader.read(it) }
|
||||||
|
runOnUiThread { if (token == loadToken) world = parsed }
|
||||||
|
}.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.compose.material.IconButton
|
|||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.RadioButton
|
import androidx.compose.material.RadioButton
|
||||||
import androidx.compose.material.Scaffold
|
import androidx.compose.material.Scaffold
|
||||||
|
import androidx.compose.material.Slider
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.material.TopAppBar
|
import androidx.compose.material.TopAppBar
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -37,6 +38,7 @@ import androidx.compose.material3.dynamicLightColorScheme
|
|||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -48,10 +50,6 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import net.helcel.beans.R
|
|
||||||
import net.helcel.beans.countries.GeoLocImporter
|
|
||||||
import net.helcel.beans.helper.Settings
|
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
@@ -60,18 +58,23 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import net.helcel.beans.R
|
||||||
import net.helcel.beans.activity.sub.AboutScreen
|
import net.helcel.beans.activity.sub.AboutScreen
|
||||||
import net.helcel.beans.activity.sub.EditPlaceColorDialog
|
import net.helcel.beans.activity.sub.EditPlaceColorDialog
|
||||||
import net.helcel.beans.activity.sub.EditPlaceDialog
|
import net.helcel.beans.countries.GeoLocImporter
|
||||||
import net.helcel.beans.activity.sub.LicenseScreen
|
import net.helcel.beans.helper.DEFAULT_TOUCH_RADIUS
|
||||||
import net.helcel.beans.helper.Data
|
import net.helcel.beans.helper.Data
|
||||||
|
import net.helcel.beans.helper.defaultPreferences
|
||||||
|
import net.helcel.beans.helper.MAX_TOUCH_RADIUS
|
||||||
|
import net.helcel.beans.helper.Settings
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SysTheme(
|
fun SysTheme(
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
val prefs = defaultPreferences(context)
|
||||||
val themeKey = prefs.getString(stringResource(R.string.key_theme), stringResource(R.string.system))
|
val themeKey = prefs.getString(stringResource(R.string.key_theme), stringResource(R.string.system))
|
||||||
val darkTheme = when (themeKey) {
|
val darkTheme = when (themeKey) {
|
||||||
stringResource(R.string.system) -> isSystemInDarkTheme()
|
stringResource(R.string.system) -> isSystemInDarkTheme()
|
||||||
@@ -100,28 +103,22 @@ fun SysTheme(
|
|||||||
isLight = !darkTheme,
|
isLight = !darkTheme,
|
||||||
)
|
)
|
||||||
|
|
||||||
MaterialTheme(
|
// Both, because the app mixes the two: most of it is Material 2, while the
|
||||||
colors = m2colors,
|
// dividers are Material 3 and would otherwise fall back to the default
|
||||||
content = content
|
// light scheme.
|
||||||
)
|
androidx.compose.material3.MaterialTheme(colorScheme = colorScheme) {
|
||||||
}
|
MaterialTheme(
|
||||||
|
colors = m2colors,
|
||||||
|
content = content
|
||||||
@Composable
|
)
|
||||||
fun settingsNav(): NavHostController {
|
|
||||||
val navController = rememberNavController()
|
|
||||||
NavHost(navController, startDestination= "settings"){
|
|
||||||
composable("settings"){SettingsScreen(navController)}
|
|
||||||
composable("licenses"){ LicenseScreen() }
|
|
||||||
composable("about"){ AboutScreen() }
|
|
||||||
}
|
}
|
||||||
return navController
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsMainScreen(onExit: ()->Unit = {}) {
|
fun SettingsMainScreen(onExit: ()->Unit = {}) {
|
||||||
val nav: NavHostController = settingsNav()
|
val nav: NavHostController = rememberNavController()
|
||||||
SysTheme {
|
SysTheme {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -141,165 +138,64 @@ fun SettingsMainScreen(onExit: ()->Unit = {}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
|
// One host, inside the scaffold. Building it here and again as a
|
||||||
|
// default argument left two of them: settings drawn twice, and
|
||||||
|
// only the inner one ever navigating.
|
||||||
Box(modifier = Modifier.padding(innerPadding)) {
|
Box(modifier = Modifier.padding(innerPadding)) {
|
||||||
SettingsScreen()
|
NavHost(nav, startDestination = "settings") {
|
||||||
|
composable("settings") { SettingsScreen(nav) }
|
||||||
|
composable("about") { AboutScreen() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(navController: NavHostController = settingsNav()) {
|
fun SettingsScreen(navController: NavHostController) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
val prefs = defaultPreferences(context)
|
||||||
val keyTheme = stringResource(R.string.key_theme)
|
val keyTheme = stringResource(R.string.key_theme)
|
||||||
val defaultTheme = stringResource(R.string.system)
|
val defaultTheme = stringResource(R.string.system)
|
||||||
val keyProjection = stringResource(R.string.key_projection)
|
val keyProjection = stringResource(R.string.key_projection)
|
||||||
val keyGroup = stringResource(R.string.key_group)
|
val keyGroup = stringResource(R.string.key_group)
|
||||||
|
val keyRegional = stringResource(R.string.key_regional)
|
||||||
|
val keyCascadeStats = stringResource(R.string.key_cascade_stats)
|
||||||
|
val keyTouchRadius = stringResource(R.string.key_touch_radius)
|
||||||
val offString = stringResource(R.string.off)
|
val offString = stringResource(R.string.off)
|
||||||
|
val onString = stringResource(R.string.on)
|
||||||
var showEdit by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
var theme by remember { mutableStateOf(prefs.getString(keyTheme, defaultTheme)!!) }
|
var theme by remember { mutableStateOf(prefs.getString(keyTheme, defaultTheme)!!) }
|
||||||
var projection by remember { mutableStateOf(prefs.getString(keyProjection, "default")!!) }
|
var projection by remember { mutableStateOf(prefs.getString(keyProjection, "default")!!) }
|
||||||
var groups by remember { mutableStateOf(prefs.getString(keyGroup,offString)!!) }
|
var groups by remember { mutableStateOf(prefs.getString(keyGroup, offString)!!) }
|
||||||
|
var regional by remember { mutableStateOf(prefs.getString(keyRegional, offString)!!) }
|
||||||
if(showEdit)
|
var cascadeStats by remember { mutableStateOf(prefs.getString(keyCascadeStats, offString)!!) }
|
||||||
EditPlaceDialog(true) {
|
var touchRadius by remember {
|
||||||
showEdit = false
|
mutableIntStateOf(prefs.getInt(keyTouchRadius, DEFAULT_TOUCH_RADIUS))
|
||||||
val g = Data.selected_group
|
|
||||||
if (it && g != null)
|
|
||||||
Data.visits.reassignAllVisitedToGroup(g.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp)
|
|
||||||
.background(MaterialTheme.colors.background)
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"Theme", style = MaterialTheme.typography.h6,
|
|
||||||
color = MaterialTheme.colors.onBackground,
|
|
||||||
)
|
|
||||||
MultiPreference(arrayOf(stringResource(R.string.system),stringResource(R.string.light),stringResource(R.string.dark)), theme) { newTheme ->
|
|
||||||
theme = newTheme
|
|
||||||
prefs.edit { putString(keyTheme, newTheme) }
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"Map Projection",
|
|
||||||
style = MaterialTheme.typography.h6,
|
|
||||||
color = MaterialTheme.colors.onBackground,
|
|
||||||
modifier = Modifier.padding(top = 16.dp)
|
|
||||||
)
|
|
||||||
MultiPreference(arrayOf(stringResource(R.string.mercator), stringResource(R.string.azimuthalequidistant)), projection) { newProj ->
|
|
||||||
projection = newProj
|
|
||||||
prefs.edit { putString(keyProjection, newProj) }
|
|
||||||
Settings.refreshProjection()
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"Groups",
|
|
||||||
style = MaterialTheme.typography.h6,
|
|
||||||
color = MaterialTheme.colors.onBackground,
|
|
||||||
modifier = Modifier.padding(top = 16.dp)
|
|
||||||
)
|
|
||||||
var showDialog by remember{mutableStateOf(false)}
|
|
||||||
if(showDialog){
|
|
||||||
EditPlaceColorDialog(
|
|
||||||
deleteMode = true,
|
|
||||||
onDismiss = {
|
|
||||||
val g = Data.selected_group
|
|
||||||
if (g != null)
|
|
||||||
Data.visits.reassignAllVisitedToGroup(g.key)
|
|
||||||
showDialog = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
MultiPreference(
|
|
||||||
arrayOf(stringResource(R.string.on), stringResource(R.string.off)),
|
|
||||||
groups
|
|
||||||
) { key ->
|
|
||||||
if (key == offString) {
|
|
||||||
showDialog=true
|
|
||||||
}
|
|
||||||
groups = key
|
|
||||||
prefs.edit { putString(keyGroup, key) }
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = "Regional",
|
|
||||||
style = MaterialTheme.typography.h6,
|
|
||||||
color = MaterialTheme.colors.onBackground,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(top = 16.dp)
|
|
||||||
.clickable(onClick = {}),
|
|
||||||
)
|
|
||||||
RegionalScreen()
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
item{
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.OpenDocument(),
|
|
||||||
onResult = { uri -> Data.doImport(context, uri) }
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Button(onClick = {
|
|
||||||
launcher.launch(arrayOf("*/*"))
|
|
||||||
}, modifier = Modifier
|
|
||||||
.fillMaxWidth(fraction = 0.4f)
|
|
||||||
.padding(vertical = 8.dp)) {
|
|
||||||
Text("Import")
|
|
||||||
}
|
|
||||||
Spacer(
|
|
||||||
modifier = Modifier.fillMaxWidth(0.4f)
|
|
||||||
)
|
|
||||||
Button(onClick = {
|
|
||||||
Data.doExport(context)
|
|
||||||
}, modifier = Modifier
|
|
||||||
.fillMaxWidth(fraction = 1f)
|
|
||||||
.padding(vertical = 8.dp)) {
|
|
||||||
Text("Export")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
PreferenceButton("Licenses") {
|
|
||||||
if (navController.currentDestination?.route != "licenses")
|
|
||||||
navController.navigate("licenses")
|
|
||||||
}
|
|
||||||
PreferenceButton("About") {
|
|
||||||
if (navController.currentDestination?.route != "about")
|
|
||||||
navController.navigate("about")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
var showGroupDialog by remember { mutableStateOf(false) }
|
||||||
fun RegionalScreen() {
|
var showRegionalDialog by remember { mutableStateOf(false) }
|
||||||
val context = LocalContext.current
|
var showLoad by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val keyRegional = stringResource(R.string.key_regional)
|
if (showGroupDialog) {
|
||||||
val offString = stringResource(R.string.off)
|
EditPlaceColorDialog(
|
||||||
val onString = stringResource(R.string.on)
|
deleteMode = true,
|
||||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
onDismiss = {
|
||||||
var selected by remember { mutableStateOf(prefs.getString(keyRegional, offString)!!)}
|
val g = Data.selected_group
|
||||||
var regional by remember{ mutableStateOf(prefs.getString(keyRegional, offString)!!)}
|
if (g != null) {
|
||||||
var showDialog by remember{mutableStateOf(false)}
|
Data.visits.reassignAllVisitedToGroup(g.key)
|
||||||
var showLoad by remember{mutableStateOf(false)}
|
Data.groups.keepOnly(g.key)
|
||||||
|
Data.saveData()
|
||||||
|
groups = offString
|
||||||
|
prefs.edit { putString(keyGroup, offString) }
|
||||||
|
}
|
||||||
|
showGroupDialog = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if(showDialog)
|
if (showRegionalDialog) {
|
||||||
Dialog(
|
Dialog(
|
||||||
content = {
|
content = {
|
||||||
Column(
|
Column(
|
||||||
@@ -308,26 +204,27 @@ fun RegionalScreen() {
|
|||||||
MaterialTheme.colors.background,
|
MaterialTheme.colors.background,
|
||||||
RoundedCornerShape(corner = CornerSize(16.dp))
|
RoundedCornerShape(corner = CornerSize(16.dp))
|
||||||
)
|
)
|
||||||
.padding(16.dp),){
|
.padding(16.dp),
|
||||||
Text(style=MaterialTheme.typography.caption, text= stringResource(R.string.delete_regions))
|
) {
|
||||||
|
Text(
|
||||||
|
style = MaterialTheme.typography.caption,
|
||||||
|
text = stringResource(R.string.delete_regions)
|
||||||
|
)
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
GeoLocImporter.clearStates()
|
GeoLocImporter.clearStates()
|
||||||
regional= selected
|
regional = offString
|
||||||
prefs.edit {
|
prefs.edit { putString(keyRegional, offString) }
|
||||||
putString(
|
showRegionalDialog = false
|
||||||
keyRegional,
|
}) {
|
||||||
regional
|
Text(stringResource(R.string.ok))
|
||||||
)
|
|
||||||
}
|
|
||||||
showDialog=false
|
|
||||||
}){
|
|
||||||
Text(stringResource(R.string.ok))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDismissRequest = { showDialog=false }
|
onDismissRequest = { showRegionalDialog = false }
|
||||||
)
|
)
|
||||||
if(showLoad){
|
}
|
||||||
|
|
||||||
|
if (showLoad) {
|
||||||
Dialog(
|
Dialog(
|
||||||
content = {
|
content = {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
@@ -339,41 +236,185 @@ fun RegionalScreen() {
|
|||||||
onDismissRequest = {}
|
onDismissRequest = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
MultiPreference(arrayOf(stringResource(R.string.on),stringResource(R.string.off)),regional) { key ->
|
LazyColumn(
|
||||||
when (key) {
|
modifier = Modifier
|
||||||
offString -> { showDialog=true
|
.fillMaxSize()
|
||||||
selected=key
|
.padding(16.dp)
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.key_theme),
|
||||||
|
style = MaterialTheme.typography.h6,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
)
|
||||||
|
MultiPreference(
|
||||||
|
arrayOf(
|
||||||
|
stringResource(R.string.system),
|
||||||
|
stringResource(R.string.light),
|
||||||
|
stringResource(R.string.dark)
|
||||||
|
), theme
|
||||||
|
) { newTheme ->
|
||||||
|
theme = newTheme
|
||||||
|
prefs.edit { putString(keyTheme, newTheme) }
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.key_projection),
|
||||||
|
style = MaterialTheme.typography.h6,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
modifier = Modifier.padding(top = 16.dp)
|
||||||
|
)
|
||||||
|
MultiPreference(
|
||||||
|
arrayOf(
|
||||||
|
stringResource(R.string.mercator),
|
||||||
|
stringResource(R.string.azimuthalequidistant),
|
||||||
|
stringResource(R.string.loximuthal),
|
||||||
|
),
|
||||||
|
projection
|
||||||
|
) { newProj ->
|
||||||
|
projection = newProj
|
||||||
|
prefs.edit { putString(keyProjection, newProj) }
|
||||||
|
Settings.refreshProjection()
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SettingSwitch(
|
||||||
|
label = stringResource(R.string.key_group),
|
||||||
|
subtitle = stringResource(R.string.key_group_desc),
|
||||||
|
isChecked = groups == onString,
|
||||||
|
onCheckedChange = { isChecked ->
|
||||||
|
if (!isChecked) {
|
||||||
|
Data.selected_group = null
|
||||||
|
showGroupDialog = true
|
||||||
|
} else {
|
||||||
|
groups = onString
|
||||||
|
prefs.edit { putString(keyGroup, onString) }
|
||||||
}
|
}
|
||||||
onString -> {
|
}
|
||||||
regional = key
|
)
|
||||||
prefs.edit { putString(keyRegional, key) }
|
HorizontalDivider()
|
||||||
showLoad=true
|
}
|
||||||
|
item {
|
||||||
|
SettingSwitch(
|
||||||
|
label = stringResource(R.string.key_regional),
|
||||||
|
subtitle = stringResource(R.string.key_regional_desc),
|
||||||
|
isChecked = regional == onString,
|
||||||
|
onCheckedChange = { isChecked ->
|
||||||
|
if (isChecked) {
|
||||||
|
regional = onString
|
||||||
|
prefs.edit { putString(keyRegional, onString) }
|
||||||
|
showLoad = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
GeoLocImporter.importStates(context, true)
|
GeoLocImporter.importStates(context, true)
|
||||||
}
|
}
|
||||||
showLoad = false
|
showLoad = false
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
showRegionalDialog = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SettingSwitch(
|
||||||
|
label = stringResource(R.string.pref_cascade_stats),
|
||||||
|
subtitle = stringResource(R.string.pref_cascade_stats_desc),
|
||||||
|
isChecked = cascadeStats == onString,
|
||||||
|
onCheckedChange = { isChecked ->
|
||||||
|
cascadeStats = if (isChecked) onString else offString
|
||||||
|
prefs.edit { putString(keyCascadeStats, cascadeStats) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.pref_touch_radius),
|
||||||
|
style = MaterialTheme.typography.h6,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
modifier = Modifier.padding(top = 16.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.pref_touch_radius_desc),
|
||||||
|
style = MaterialTheme.typography.body2,
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Slider(
|
||||||
|
value = touchRadius.toFloat(),
|
||||||
|
onValueChange = { touchRadius = it.roundToInt() },
|
||||||
|
onValueChangeFinished = { prefs.edit { putInt(keyTouchRadius, touchRadius) } },
|
||||||
|
valueRange = 0f..MAX_TOUCH_RADIUS.toFloat(),
|
||||||
|
steps = MAX_TOUCH_RADIUS - 1,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.pref_touch_radius_value, touchRadius),
|
||||||
|
style = MaterialTheme.typography.body1,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
modifier = Modifier.padding(start = 12.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
onResult = { uri -> Data.doImport(context, uri) }
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
launcher.launch(arrayOf("*/*"))
|
||||||
|
}, modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_import))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
Data.doExport(context)
|
||||||
|
}, modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_export))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
PreferenceButton(stringResource(R.string.about)) {
|
||||||
|
if (navController.currentDestination?.route != "about")
|
||||||
|
navController.navigate("about")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MultiPreference(list: Array<String>, selected: String, onSelected: (String) -> Unit) {
|
fun MultiPreference(list: Array<String>, selected: String, onSelected: (String) -> Unit) {
|
||||||
Column(Modifier.padding(2.dp)) {
|
Column(Modifier.padding(vertical = 2.dp)) {
|
||||||
list.forEach { value ->
|
list.forEach { value ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(36.dp)
|
.height(42.dp)
|
||||||
.clickable { onSelected(value) }) {
|
.clickable { onSelected(value) }) {
|
||||||
RadioButton(selected = selected == value, onClick = { onSelected(value) })
|
RadioButton(selected = selected == value, onClick = { onSelected(value) })
|
||||||
Text(
|
Text(
|
||||||
value, modifier = Modifier.padding(start = 8.dp),
|
value, modifier = Modifier.padding(start = 12.dp),
|
||||||
|
style = MaterialTheme.typography.body1,
|
||||||
color = MaterialTheme.colors.onBackground,
|
color = MaterialTheme.colors.onBackground,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -388,4 +429,39 @@ fun PreferenceButton(text: String, onClick: () -> Unit) {
|
|||||||
.padding(vertical = 8.dp)) {
|
.padding(vertical = 8.dp)) {
|
||||||
Text(text)
|
Text(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingSwitch(
|
||||||
|
label: String,
|
||||||
|
subtitle: String? = null,
|
||||||
|
isChecked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onCheckedChange(!isChecked) }
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text=label,
|
||||||
|
style = MaterialTheme.typography.h6,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
)
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.body2,
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidx.compose.material.Switch(
|
||||||
|
checked = isChecked,
|
||||||
|
onCheckedChange = onCheckedChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import androidx.compose.material.TopAppBar
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -32,12 +33,15 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import net.helcel.beans.R
|
import net.helcel.beans.R
|
||||||
|
import net.helcel.beans.countries.Country
|
||||||
|
import net.helcel.beans.countries.GeoLoc
|
||||||
import net.helcel.beans.countries.GeoLoc.LocType
|
import net.helcel.beans.countries.GeoLoc.LocType
|
||||||
import net.helcel.beans.countries.World
|
import net.helcel.beans.countries.World
|
||||||
import net.helcel.beans.helper.AUTO_GROUP
|
import net.helcel.beans.helper.AUTO_GROUP
|
||||||
import net.helcel.beans.helper.Data
|
import net.helcel.beans.helper.Data
|
||||||
import net.helcel.beans.helper.Groups
|
import net.helcel.beans.helper.Groups
|
||||||
import net.helcel.beans.helper.Settings.isRegional
|
import net.helcel.beans.helper.NO_GROUP
|
||||||
|
import net.helcel.beans.helper.Settings
|
||||||
import net.helcel.beans.helper.Theme.getContrastColor
|
import net.helcel.beans.helper.Theme.getContrastColor
|
||||||
|
|
||||||
private val MODE_LIST = listOf(LocType.WORLD, LocType.COUNTRY, LocType.STATE)
|
private val MODE_LIST = listOf(LocType.WORLD, LocType.COUNTRY, LocType.STATE)
|
||||||
@@ -46,7 +50,7 @@ private val MODE_LIST = listOf(LocType.WORLD, LocType.COUNTRY, LocType.STATE)
|
|||||||
fun StatsScreen(
|
fun StatsScreen(
|
||||||
onExit: ()-> Unit
|
onExit: ()-> Unit
|
||||||
) {
|
) {
|
||||||
val modes = if (isRegional(LocalContext.current)) MODE_LIST else MODE_LIST.take(2)
|
val modes = if (Settings.isRegional(LocalContext.current)) MODE_LIST else MODE_LIST.take(2)
|
||||||
var selectedTab by remember { mutableIntStateOf(0) }
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
var countMode by remember { mutableStateOf(true) }
|
var countMode by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
@@ -57,7 +61,7 @@ fun StatsScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically){
|
Row(verticalAlignment = Alignment.CenterVertically){
|
||||||
Text(text=stringResource(R.string.action_edit), modifier = Modifier.weight(1f))
|
Text(text=stringResource(R.string.action_stat), modifier = Modifier.weight(1f))
|
||||||
Button(onClick = { countMode = !countMode }) {
|
Button(onClick = { countMode = !countMode }) {
|
||||||
Text(if (countMode) "Count" else "Area")
|
Text(if (countMode) "Count" else "Area")
|
||||||
}
|
}
|
||||||
@@ -115,26 +119,83 @@ fun StatsList(activeMode: LocType, countMode: Boolean) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StatsRow(group: Groups.Group, activeMode: LocType, countMode: Boolean) {
|
fun StatsRow(group: Groups.Group, activeMode: LocType, countMode: Boolean) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val isCascadeStats = remember { Settings.isCascadeStats(context) }
|
||||||
|
val visits by Data.visits.visitsFlow.collectAsState()
|
||||||
|
|
||||||
val visited = remember(group, activeMode) {
|
val continents = remember { World.WWW.children.toList() }
|
||||||
Data.visits.getVisitedByValue(group.key)
|
val countries = remember {
|
||||||
|
World.WWW.children.flatMap {
|
||||||
|
if (it is Country) listOf(it) else it.children
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val count = when (activeMode) {
|
val visitedItems = remember(group, activeMode, isCascadeStats, visits) {
|
||||||
LocType.WORLD -> World.WWW.children.filter { it.code in visited }.size
|
val list = when (activeMode) {
|
||||||
LocType.COUNTRY -> World.WWW.children.flatMap { it.children.filter { c -> c.code in visited } }.size
|
LocType.WORLD -> continents
|
||||||
LocType.STATE -> World.WWW.children.flatMap { a->a.children.flatMap { b->b.children.filter { c->c.code in visited } } }.size
|
LocType.COUNTRY -> countries
|
||||||
else -> 0
|
LocType.STATE -> countries.flatMap { it.children }
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getExplicitColor(l: GeoLoc): Int? {
|
||||||
|
val c = visits.getOrDefault(l.code, 0)
|
||||||
|
return if (c != NO_GROUP && c != AUTO_GROUP) c else null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getEffectiveGroup(l: GeoLoc): Int {
|
||||||
|
getExplicitColor(l)?.let { return it }
|
||||||
|
|
||||||
|
if (isCascadeStats) {
|
||||||
|
// Parent -> Child (closest explicit ancestor)
|
||||||
|
val country = countries.find { it.children.contains(l) }
|
||||||
|
val countryColor = country?.let { getExplicitColor(it) }
|
||||||
|
if (countryColor != null) return countryColor
|
||||||
|
|
||||||
|
val continent = continents.find {
|
||||||
|
it.children.contains(l) || (country != null && it.children.contains(country))
|
||||||
|
}
|
||||||
|
val continentColor = continent?.let { getExplicitColor(it) }
|
||||||
|
if (continentColor != null) return continentColor
|
||||||
|
|
||||||
|
val worldColor = getExplicitColor(World.WWW)
|
||||||
|
if (worldColor != null) return worldColor
|
||||||
|
|
||||||
|
// Child -> Parent (most common explicit descendant)
|
||||||
|
val descendants = mutableListOf<GeoLoc>()
|
||||||
|
fun collect(curr: GeoLoc) {
|
||||||
|
curr.children.forEach {
|
||||||
|
descendants.add(it)
|
||||||
|
collect(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collect(l)
|
||||||
|
val descendantColors = descendants.mapNotNull { getExplicitColor(it) }
|
||||||
|
if (descendantColors.isNotEmpty()) {
|
||||||
|
return descendantColors.groupBy { it }.maxByOrNull { it.value.size }?.key ?: AUTO_GROUP
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (l.type == LocType.WORLD || l.type == LocType.COUNTRY) {
|
||||||
|
val descendants = mutableListOf<GeoLoc>()
|
||||||
|
fun collect(curr: GeoLoc) {
|
||||||
|
curr.children.forEach { descendants.add(it); collect(it) }
|
||||||
|
}
|
||||||
|
collect(l)
|
||||||
|
val dc = descendants.mapNotNull { getExplicitColor(it) }
|
||||||
|
if (dc.isNotEmpty()) return dc.groupBy { it }.maxByOrNull { it.value.size }?.key ?: AUTO_GROUP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AUTO_GROUP
|
||||||
|
}
|
||||||
|
|
||||||
|
list.filter { getEffectiveGroup(it) == group.key }
|
||||||
}
|
}
|
||||||
|
|
||||||
val area = when (activeMode) {
|
val count = visitedItems.size
|
||||||
LocType.WORLD -> World.WWW.children.filter { it.code in visited }.sumOf { it.area }
|
val area = visitedItems.sumOf { it.area.toLong() }
|
||||||
LocType.COUNTRY -> World.WWW.children.flatMap { it.children.filter { c -> c.code in visited } }.sumOf { it.area }
|
|
||||||
LocType.STATE -> World.WWW.children.flatMap { a->a.children.flatMap { b->b.children.filter { c->c.code in visited } } }.sumOf { it.area }
|
|
||||||
else -> 0
|
|
||||||
}
|
|
||||||
|
|
||||||
val displayValue = if (countMode) count.toString() else stringResource(R.string.number_with_unit, area, "km²")
|
val displayValue = if (countMode) count.toString() else stringResource(R.string.number_with_unit, area.toInt(), "km²")
|
||||||
|
|
||||||
val backgroundColor = group.color.color
|
val backgroundColor = group.color.color
|
||||||
val textColor = getContrastColor(backgroundColor)
|
val textColor = getContrastColor(backgroundColor)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package net.helcel.beans.activity.sub
|
package net.helcel.beans.activity.sub
|
||||||
|
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -54,27 +55,89 @@ fun EditPlaceScreenPreview(){
|
|||||||
EditPlaceScreen(Group.EEE)
|
EditPlaceScreen(Group.EEE)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun syncVisited(loc: GeoLoc?=World.WWW){
|
/**
|
||||||
|
* Applies a place straight away where a single group makes the choice obvious,
|
||||||
|
* as tapping the checkbox does. Returns false when the group still has to be
|
||||||
|
* picked from the colour dialog.
|
||||||
|
*/
|
||||||
|
fun applyDirectVisit(ctx: Context, loc: GeoLoc): Boolean {
|
||||||
|
Data.selected_geoloc = loc
|
||||||
|
Data.selected_group = null
|
||||||
|
if (Data.groups.size() != 1 || !Settings.isSingleGroup(ctx)) return false
|
||||||
|
val current = Data.visits.getVisited(loc)
|
||||||
|
Data.visits.setVisited(
|
||||||
|
loc,
|
||||||
|
if (current == NO_GROUP || current == AUTO_GROUP) Data.groups.getUniqueEntry()!!.key
|
||||||
|
else if (loc.children.any { Data.visits.getVisited(it) != NO_GROUP }) AUTO_GROUP
|
||||||
|
else NO_GROUP,
|
||||||
|
)
|
||||||
|
syncVisited()
|
||||||
|
Data.saveData()
|
||||||
|
Data.selected_geoloc = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores whatever the colour dialog came back with. */
|
||||||
|
fun commitVisitDialog(cleared: Boolean) {
|
||||||
|
if (cleared) {
|
||||||
|
Data.visits.setVisited(Data.selected_geoloc, NO_GROUP)
|
||||||
|
syncVisited()
|
||||||
|
Data.saveData()
|
||||||
|
}
|
||||||
|
if (Data.selected_group != null && Data.selected_geoloc != null) {
|
||||||
|
Data.visits.setVisited(Data.selected_geoloc, Data.selected_group!!.key)
|
||||||
|
syncVisited()
|
||||||
|
Data.saveData()
|
||||||
|
}
|
||||||
|
Data.selected_geoloc = null
|
||||||
|
Data.selected_group = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncVisited(loc: GeoLoc?=World.WWW): Boolean {
|
||||||
|
var changed = false
|
||||||
loc?.children?.forEach { tt ->
|
loc?.children?.forEach { tt ->
|
||||||
tt.children.forEach {itc->
|
tt.children.forEach {itc->
|
||||||
if(Data.visits.getVisited(itc) in listOf(AUTO_GROUP,NO_GROUP)) {
|
if(Data.visits.getVisited(itc) in listOf(AUTO_GROUP,NO_GROUP)) {
|
||||||
if(itc.children.any { c -> Data.visits.getVisited(c) != NO_GROUP })
|
val newState = if(itc.children.any { c -> Data.visits.getVisited(c) != NO_GROUP })
|
||||||
Data.visits.setVisited(itc, AUTO_GROUP)
|
AUTO_GROUP
|
||||||
else
|
else
|
||||||
Data.visits.setVisited(itc, NO_GROUP)
|
NO_GROUP
|
||||||
|
|
||||||
|
if (Data.visits.getVisited(itc) != newState) {
|
||||||
|
Data.visits.setVisited(itc, newState)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(Data.visits.getVisited(tt) in listOf(AUTO_GROUP,NO_GROUP)) {
|
if(Data.visits.getVisited(tt) in listOf(AUTO_GROUP,NO_GROUP)) {
|
||||||
if(tt.children.any { itc -> Data.visits.getVisited(itc) != NO_GROUP })
|
val newState = if(tt.children.any { itc -> Data.visits.getVisited(itc) != NO_GROUP })
|
||||||
Data.visits.setVisited(tt, AUTO_GROUP)
|
AUTO_GROUP
|
||||||
else
|
else
|
||||||
Data.visits.setVisited(tt, NO_GROUP)
|
NO_GROUP
|
||||||
|
|
||||||
|
if (Data.visits.getVisited(tt) != newState) {
|
||||||
|
Data.visits.setVisited(tt, newState)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sync World from Continents
|
||||||
|
if (loc != null && Data.visits.getVisited(loc) in listOf(AUTO_GROUP, NO_GROUP)) {
|
||||||
|
val newState = if(loc.children.any { Data.visits.getVisited(it) != NO_GROUP })
|
||||||
|
AUTO_GROUP
|
||||||
|
else
|
||||||
|
NO_GROUP
|
||||||
|
if (Data.visits.getVisited(loc) != newState) {
|
||||||
|
Data.visits.setVisited(loc, newState)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
||||||
|
val visits by Data.visits.visitsFlow.collectAsState()
|
||||||
var showEdit by remember { mutableStateOf(false) }
|
var showEdit by remember { mutableStateOf(false) }
|
||||||
val tabs : SnapshotStateList<GeoLoc> = remember { mutableStateListOf(loc) }
|
val tabs : SnapshotStateList<GeoLoc> = remember { mutableStateListOf(loc) }
|
||||||
val ctx = LocalContext.current
|
val ctx = LocalContext.current
|
||||||
@@ -84,7 +147,12 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
|||||||
selectedTab = tabs.lastIndex
|
selectedTab = tabs.lastIndex
|
||||||
}
|
}
|
||||||
SideEffect {
|
SideEffect {
|
||||||
syncVisited()
|
// visits is used to trigger sync whenever data changes
|
||||||
|
if (visits.isNotEmpty() || true) {
|
||||||
|
if (syncVisited()) {
|
||||||
|
Data.saveData()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
BackHandler {
|
BackHandler {
|
||||||
if (tabs.size > 1) tabs.removeAt(tabs.lastIndex)
|
if (tabs.size > 1) tabs.removeAt(tabs.lastIndex)
|
||||||
@@ -93,20 +161,7 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
|||||||
if(showEdit)
|
if(showEdit)
|
||||||
EditPlaceDialog(false) {
|
EditPlaceDialog(false) {
|
||||||
showEdit = false
|
showEdit = false
|
||||||
if (it) {
|
commitVisitDialog(it)
|
||||||
Data.visits.setVisited(Data.selected_geoloc, NO_GROUP)
|
|
||||||
Data.saveData()
|
|
||||||
|
|
||||||
if (Data.selected_geoloc!=null && Data.selected_geoloc!!.children.any { itc-> Data.visits.getVisited(itc) != NO_GROUP }) {
|
|
||||||
Data.clearing_geoloc = Data.selected_geoloc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Data.selected_group != null && Data.selected_geoloc != null) {
|
|
||||||
Data.visits.setVisited(Data.selected_geoloc, Data.selected_group!!.key)
|
|
||||||
Data.saveData()
|
|
||||||
}
|
|
||||||
Data.selected_geoloc = null
|
|
||||||
Data.selected_group = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
@@ -136,20 +191,7 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
|||||||
tabs.add(loc)
|
tabs.add(loc)
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
Data.selected_geoloc = loc
|
if (!applyDirectVisit(ctx, loc)) showEdit = true
|
||||||
if (Data.groups.size() == 1 && Settings.isSingleGroup(ctx)) {
|
|
||||||
Data.visits.setVisited(Data.selected_geoloc,
|
|
||||||
if (it != ToggleableState.On) Data.groups.getUniqueEntry()!!.key
|
|
||||||
else if(Data.selected_geoloc?.children?.any{ itc->
|
|
||||||
Data.visits.getVisited(itc)!= NO_GROUP } == true) AUTO_GROUP
|
|
||||||
else NO_GROUP
|
|
||||||
)
|
|
||||||
Data.selected_group = null
|
|
||||||
} else {
|
|
||||||
Data.selected_group = null
|
|
||||||
showEdit=true
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -163,7 +205,7 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
|||||||
fun GeoLocRow(
|
fun GeoLocRow(
|
||||||
loc: GeoLoc,
|
loc: GeoLoc,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onCheckedChange: (ToggleableState) -> Unit
|
onToggle: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val visits by Data.visits.visitsFlow.collectAsState()
|
val visits by Data.visits.visitsFlow.collectAsState()
|
||||||
val checked by remember(visits, loc) {
|
val checked by remember(visits, loc) {
|
||||||
@@ -202,7 +244,7 @@ fun GeoLocRow(
|
|||||||
|
|
||||||
TriStateCheckbox(
|
TriStateCheckbox(
|
||||||
state = checked,
|
state = checked,
|
||||||
onClick= { onCheckedChange(checked) },
|
onClick = onToggle,
|
||||||
colors = CheckboxDefaults.colors(
|
colors = CheckboxDefaults.colors(
|
||||||
checkedColor = color,
|
checkedColor = color,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package net.helcel.beans.activity.sub
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.material.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.DefaultChipColors
|
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.DefaultLibraryColors
|
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
|
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
|
||||||
import net.helcel.beans.R
|
|
||||||
import net.helcel.beans.activity.SysTheme
|
|
||||||
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun LicenseScreen() {
|
|
||||||
val libraries = produceLibraries(R.raw.aboutlibraries)
|
|
||||||
SysTheme {
|
|
||||||
LibrariesContainer(
|
|
||||||
libraries = libraries.value,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
colors = DefaultLibraryColors(
|
|
||||||
libraryBackgroundColor = MaterialTheme.colors.background,
|
|
||||||
libraryContentColor = MaterialTheme.colors.onBackground,
|
|
||||||
licenseChipColors = DefaultChipColors(
|
|
||||||
containerColor = MaterialTheme.colors.primary,
|
|
||||||
contentColor = MaterialTheme.colors.onPrimary,
|
|
||||||
),
|
|
||||||
versionChipColors = DefaultChipColors(
|
|
||||||
containerColor = MaterialTheme.colors.secondary,
|
|
||||||
contentColor = MaterialTheme.colors.onSecondary,
|
|
||||||
),
|
|
||||||
fundingChipColors = DefaultChipColors(
|
|
||||||
containerColor = MaterialTheme.colors.secondary,
|
|
||||||
contentColor = MaterialTheme.colors.onSecondary,
|
|
||||||
),
|
|
||||||
dialogConfirmButtonColor = MaterialTheme.colors.primary,
|
|
||||||
dialogBackgroundColor = MaterialTheme.colors.onPrimary,
|
|
||||||
dialogContentColor = MaterialTheme.colors.primary,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package net.helcel.beans.activity.sub
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import net.helcel.beans.R
|
||||||
|
import net.helcel.beans.activity.SysTheme
|
||||||
|
import net.helcel.beans.countries.GeoLoc
|
||||||
|
import net.helcel.beans.countries.GeoLocTree
|
||||||
|
import net.helcel.beans.helper.AUTO_GROUP
|
||||||
|
import net.helcel.beans.helper.Data
|
||||||
|
import net.helcel.beans.helper.NO_GROUP
|
||||||
|
|
||||||
|
private class PickRow(val loc: GeoLoc, val depth: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the shapes a tap landed near into an indented list.
|
||||||
|
*
|
||||||
|
* Each candidate brings its continent and country along, so a tap that is
|
||||||
|
* ambiguous between two regions can still be answered with "the whole country"
|
||||||
|
* or "the whole continent" instead of only the regions themselves.
|
||||||
|
*/
|
||||||
|
private fun rowsFor(candidates: List<GeoLoc>): List<PickRow> {
|
||||||
|
val rows = LinkedHashMap<String, PickRow>()
|
||||||
|
candidates.forEach { candidate ->
|
||||||
|
GeoLocTree.chain(candidate).forEachIndexed { depth, loc ->
|
||||||
|
rows.getOrPut(loc.code) { PickRow(loc, depth) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.values.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MapPickDialog(
|
||||||
|
candidates: List<GeoLoc>,
|
||||||
|
onPick: (GeoLoc) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val visits by Data.visits.visitsFlow.collectAsState()
|
||||||
|
val rows = remember(candidates) { rowsFor(candidates) }
|
||||||
|
|
||||||
|
SysTheme {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colors.background,
|
||||||
|
RoundedCornerShape(corner = CornerSize(16.dp)),
|
||||||
|
)
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
style = MaterialTheme.typography.h6,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
text = stringResource(R.string.select_place),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
style = MaterialTheme.typography.caption,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
text = stringResource(R.string.select_place_sub),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 360.dp),
|
||||||
|
) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
items(rows, key = { it.loc.code }) { row ->
|
||||||
|
val group = visits.getOrElse(row.loc.code) { NO_GROUP }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onPick(row.loc) }
|
||||||
|
.background(
|
||||||
|
Color(88, 88, 88, 88),
|
||||||
|
RoundedCornerShape(corner = CornerSize(16.dp)),
|
||||||
|
)
|
||||||
|
.padding(
|
||||||
|
start = 8.dp + 16.dp * row.depth,
|
||||||
|
top = 8.dp,
|
||||||
|
end = 8.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.background(
|
||||||
|
if (group == NO_GROUP || group == AUTO_GROUP) {
|
||||||
|
MaterialTheme.colors.onBackground
|
||||||
|
} else {
|
||||||
|
Color(
|
||||||
|
Data.groups.getGroupFromKey(group).color.color
|
||||||
|
)
|
||||||
|
},
|
||||||
|
CircleShape,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
style = MaterialTheme.typography.body2,
|
||||||
|
text = row.loc.fullName,
|
||||||
|
// Long names wrap inside the row rather
|
||||||
|
// than running off the end of it.
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ object GeoLocImporter {
|
|||||||
|
|
||||||
fun importStates(ctx: Context, force: Boolean = false) {
|
fun importStates(ctx: Context, force: Boolean = false) {
|
||||||
if (!Settings.isRegional(ctx) and !force) {
|
if (!Settings.isRegional(ctx) and !force) {
|
||||||
|
GeoLocTree.rebuild()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val fs = BufferedReader(InputStreamReader(ctx.assets.open("geoloc_state.txt")))
|
val fs = BufferedReader(InputStreamReader(ctx.assets.open("geoloc_state.txt")))
|
||||||
@@ -26,6 +27,7 @@ object GeoLocImporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
GeoLocTree.rebuild()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearStates() {
|
fun clearStates() {
|
||||||
@@ -39,6 +41,7 @@ object GeoLocImporter {
|
|||||||
}
|
}
|
||||||
country.children.clear()
|
country.children.clear()
|
||||||
}
|
}
|
||||||
|
GeoLocTree.rebuild()
|
||||||
Data.saveData()
|
Data.saveData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package net.helcel.beans.countries
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code to place lookup, plus the chain of parents above a place.
|
||||||
|
*
|
||||||
|
* Regions only exist once [GeoLocImporter] has read them, so this is rebuilt
|
||||||
|
* whenever that set changes rather than derived once and cached forever.
|
||||||
|
*/
|
||||||
|
object GeoLocTree {
|
||||||
|
|
||||||
|
private var index: Map<String, GeoLoc> = emptyMap()
|
||||||
|
private var parents: Map<String, GeoLoc> = emptyMap()
|
||||||
|
|
||||||
|
fun rebuild() {
|
||||||
|
val byCode = HashMap<String, GeoLoc>()
|
||||||
|
val parent = HashMap<String, GeoLoc>()
|
||||||
|
byCode[World.WWW.code] = World.WWW
|
||||||
|
World.WWW.children.forEach { child ->
|
||||||
|
byCode[child.code] = child
|
||||||
|
parent[child.code] = World.WWW
|
||||||
|
child.children.forEach { country ->
|
||||||
|
byCode[country.code] = country
|
||||||
|
parent[country.code] = child
|
||||||
|
country.children.forEach { state ->
|
||||||
|
byCode[state.code] = state
|
||||||
|
parent[state.code] = country
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index = byCode
|
||||||
|
parents = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
fun find(code: String): GeoLoc? {
|
||||||
|
if (index.isEmpty()) rebuild()
|
||||||
|
return index[code]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [loc] and everything above it, world first, so it reads as a path. */
|
||||||
|
fun chain(loc: GeoLoc): List<GeoLoc> {
|
||||||
|
if (index.isEmpty()) rebuild()
|
||||||
|
val path = ArrayList<GeoLoc>(4)
|
||||||
|
var current: GeoLoc? = loc
|
||||||
|
while (current != null && current != World.WWW) {
|
||||||
|
path.add(current)
|
||||||
|
current = parents[current.code]
|
||||||
|
}
|
||||||
|
return path.reversed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import android.os.Build
|
|||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import net.helcel.beans.R
|
import net.helcel.beans.R
|
||||||
import net.helcel.beans.countries.GeoLoc
|
import net.helcel.beans.countries.GeoLoc
|
||||||
import java.util.HashMap
|
import java.util.HashMap
|
||||||
@@ -23,7 +22,6 @@ object Data {
|
|||||||
|
|
||||||
var selected_group : Groups.Group? = null
|
var selected_group : Groups.Group? = null
|
||||||
var selected_geoloc: GeoLoc? = null
|
var selected_geoloc: GeoLoc? = null
|
||||||
var clearing_geoloc: GeoLoc? = null
|
|
||||||
|
|
||||||
private val groupsSerial = Groups.GroupsSerializer()
|
private val groupsSerial = Groups.GroupsSerializer()
|
||||||
private val visitsSerial = Visits.VisitsSerializer()
|
private val visitsSerial = Visits.VisitsSerializer()
|
||||||
@@ -31,7 +29,7 @@ object Data {
|
|||||||
private lateinit var sharedPreferences: SharedPreferences
|
private lateinit var sharedPreferences: SharedPreferences
|
||||||
|
|
||||||
fun loadData(ctx: Context, id:Int) {
|
fun loadData(ctx: Context, id:Int) {
|
||||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(ctx)
|
sharedPreferences = defaultPreferences(ctx)
|
||||||
|
|
||||||
val groupsString = sharedPreferences.getString("groups_$id",null)
|
val groupsString = sharedPreferences.getString("groups_$id",null)
|
||||||
val visitsString = sharedPreferences.getString("visits_$id",null)
|
val visitsString = sharedPreferences.getString("visits_$id",null)
|
||||||
@@ -43,14 +41,14 @@ object Data {
|
|||||||
if (groups.size() == 0) {
|
if (groups.size() == 0) {
|
||||||
groups.setGroup(DEFAULT_GROUP, "Visited",
|
groups.setGroup(DEFAULT_GROUP, "Visited",
|
||||||
ContextCompat.getColor(ctx, R.color.blue).toDrawable())
|
ContextCompat.getColor(ctx, R.color.blue).toDrawable())
|
||||||
saveData()
|
|
||||||
}
|
}
|
||||||
|
saveData()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveData() {
|
fun saveData() {
|
||||||
if(groups.id != visits.id) return
|
if(groups.id != visits.id) return
|
||||||
val id = groups.id
|
val id = groups.id
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit(commit=true) {
|
||||||
putString("groups_$id", groupsSerial.writeTo(groups))
|
putString("groups_$id", groupsSerial.writeTo(groups))
|
||||||
putString("visits_$id", visitsSerial.writeTo(visits))
|
putString("visits_$id", visitsSerial.writeTo(visits))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,25 @@ class Groups(val id: Int, @SerialName("grps") private val groups: HashMap<Int, G
|
|||||||
val groupsFlow: StateFlow<List<Group>> = _groupsFlow.asStateFlow()
|
val groupsFlow: StateFlow<List<Group>> = _groupsFlow.asStateFlow()
|
||||||
|
|
||||||
fun setGroup(key: Int, name: String, col: ColorDrawable) {
|
fun setGroup(key: Int, name: String, col: ColorDrawable) {
|
||||||
|
val old = groups[key]
|
||||||
|
if (old != null && old.name == name && old.color.color == col.color) return
|
||||||
groups[key] = Group(key, name, col)
|
groups[key] = Group(key, name, col)
|
||||||
_groupsFlow.value = groups.values.toList()
|
updateFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteGroup(key: Int) {
|
fun deleteGroup(key: Int) {
|
||||||
groups.remove(key)
|
groups.remove(key)
|
||||||
|
updateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun keepOnly(key: Int) {
|
||||||
|
val keep = groups[key] ?: return
|
||||||
|
groups.clear()
|
||||||
|
groups[key] = keep
|
||||||
|
updateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateFlow() {
|
||||||
_groupsFlow.value = groups.values.toList()
|
_groupsFlow.value = groups.values.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package net.helcel.beans.helper
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preference file opened directly, under the name and mode androidx's
|
||||||
|
* PreferenceManager used. Settings written by earlier versions still load, and
|
||||||
|
* the app no longer pulls in the preference UI framework for one call.
|
||||||
|
*/
|
||||||
|
fun defaultPreferences(ctx: Context): SharedPreferences =
|
||||||
|
ctx.getSharedPreferences(ctx.packageName + "_preferences", Context.MODE_PRIVATE)
|
||||||
@@ -2,17 +2,26 @@ package net.helcel.beans.helper
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import net.helcel.beans.R
|
import net.helcel.beans.R
|
||||||
import net.helcel.beans.activity.MainScreen
|
import net.helcel.beans.activity.MainScreen
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default distance, in dp, around a tap that still counts as hitting a place.
|
||||||
|
* Kept small because the radius is measured on screen: zoomed out to the whole
|
||||||
|
* world, even a few dp reach across several countries.
|
||||||
|
*/
|
||||||
|
const val DEFAULT_TOUCH_RADIUS = 4
|
||||||
|
|
||||||
|
/** Largest tap radius the setting offers. */
|
||||||
|
const val MAX_TOUCH_RADIUS = 48
|
||||||
|
|
||||||
object Settings {
|
object Settings {
|
||||||
|
|
||||||
private lateinit var sp: SharedPreferences
|
private lateinit var sp: SharedPreferences
|
||||||
private lateinit var mainActivity: MainScreen
|
private lateinit var mainActivity: MainScreen
|
||||||
fun start(ctx: MainScreen) {
|
fun start(ctx: MainScreen) {
|
||||||
mainActivity = ctx
|
mainActivity = ctx
|
||||||
sp = PreferenceManager.getDefaultSharedPreferences(ctx)
|
sp = defaultPreferences(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isSingleGroup(ctx: Context): Boolean {
|
fun isSingleGroup(ctx: Context): Boolean {
|
||||||
@@ -29,6 +38,21 @@ object Settings {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far around a tap, in dp, the map looks for other places. Anything
|
||||||
|
* within it is offered as a candidate instead of painting straight away.
|
||||||
|
*/
|
||||||
|
fun getTouchRadius(ctx: Context): Float {
|
||||||
|
return sp.getInt(ctx.getString(R.string.key_touch_radius), DEFAULT_TOUCH_RADIUS).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isCascadeStats(ctx: Context): Boolean {
|
||||||
|
return getBooleanValue(
|
||||||
|
ctx,
|
||||||
|
sp.getString(ctx.getString(R.string.key_cascade_stats), ctx.getString(R.string.off))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun getStatPref(ctx: Context): String? {
|
fun getStatPref(ctx: Context): String? {
|
||||||
return sp.getString(
|
return sp.getString(
|
||||||
ctx.getString(R.string.key_stats),
|
ctx.getString(R.string.key_stats),
|
||||||
|
|||||||
@@ -19,24 +19,25 @@ class Visits(val id: Int, private val locs: HashMap<String, Int>) {
|
|||||||
val visitsFlow: StateFlow<Map<String,Int>> = _visitsFlow
|
val visitsFlow: StateFlow<Map<String,Int>> = _visitsFlow
|
||||||
|
|
||||||
fun setVisited(key: GeoLoc?, b: Int) {
|
fun setVisited(key: GeoLoc?, b: Int) {
|
||||||
if (key == null)
|
if (key == null || locs[key.code] == b)
|
||||||
return
|
return
|
||||||
_visitsFlow.value = _visitsFlow.value.toMutableMap().apply {
|
|
||||||
this[key.code] = b
|
|
||||||
}
|
|
||||||
locs[key.code] = b
|
locs[key.code] = b
|
||||||
|
updateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateFlow() {
|
||||||
|
_visitsFlow.value = locs.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteVisited(key: Int) {
|
fun deleteVisited(key: Int) {
|
||||||
val keysToDelete = locs
|
val keysToDelete = locs
|
||||||
.filter { it.value == key }
|
.filter { it.value == key }
|
||||||
.map { it.key }
|
.map { it.key }
|
||||||
_visitsFlow.value = _visitsFlow.value.toMutableMap().apply {
|
if (keysToDelete.isEmpty()) return
|
||||||
keysToDelete.forEach { this.remove(it)}
|
|
||||||
}
|
|
||||||
keysToDelete.forEach {
|
keysToDelete.forEach {
|
||||||
locs.remove(it)
|
locs.remove(it)
|
||||||
}
|
}
|
||||||
|
updateFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getVisited(key: GeoLoc): Int {
|
fun getVisited(key: GeoLoc): Int {
|
||||||
@@ -60,13 +61,19 @@ class Visits(val id: Int, private val locs: HashMap<String, Int>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun reassignAllVisitedToGroup(group: Int) {
|
fun reassignAllVisitedToGroup(group: Int) {
|
||||||
|
var changed = false
|
||||||
val keys = locs.filter { (_, grp) ->
|
val keys = locs.filter { (_, grp) ->
|
||||||
grp !in listOf(NO_GROUP, AUTO_GROUP)
|
grp !in listOf(NO_GROUP, AUTO_GROUP)
|
||||||
}.keys
|
}.keys
|
||||||
keys.forEach {
|
keys.forEach {
|
||||||
locs[it] = group
|
if (locs[it] != group) {
|
||||||
|
locs[it] = group
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
updateFlow()
|
||||||
}
|
}
|
||||||
_visitsFlow.value = locs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package net.helcel.beans.map
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import net.helcel.beans.R
|
||||||
|
import net.helcel.beans.helper.defaultPreferences
|
||||||
|
|
||||||
|
/** Picks the map asset matching the projection the user chose. */
|
||||||
|
object MapAssets {
|
||||||
|
|
||||||
|
fun assetFor(ctx: Context): String {
|
||||||
|
val preferences = defaultPreferences(ctx)
|
||||||
|
return when (
|
||||||
|
preferences.getString(
|
||||||
|
ctx.getString(R.string.key_projection),
|
||||||
|
ctx.getString(R.string.mercator),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ctx.getString(R.string.azimuthalequidistant) -> "aeqd01.bmap"
|
||||||
|
ctx.getString(R.string.loximuthal) -> "loxim01.bmap"
|
||||||
|
else -> "webmercator01.bmap"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package net.helcel.beans.map
|
||||||
|
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.graphics.RectF
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
private const val VERSION = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the packed map assets produced by `packmap.js`.
|
||||||
|
*
|
||||||
|
* The maps used to ship as SVG, which spends around four megabytes per
|
||||||
|
* projection spelling coordinates out in decimal ASCII inside XML. The packed
|
||||||
|
* form says the same thing in a fraction of that, and reading it is a straight
|
||||||
|
* scan over a byte array instead of an XML parse with a float scanner on top.
|
||||||
|
*
|
||||||
|
* The format is documented in `packmap.js`; the two have to agree.
|
||||||
|
*/
|
||||||
|
object MapReader {
|
||||||
|
|
||||||
|
fun read(input: InputStream): MapWorld {
|
||||||
|
val reader = ByteReader(input.readBytes())
|
||||||
|
reader.readHeader()
|
||||||
|
val width = reader.unsigned() * reader.scale
|
||||||
|
val height = reader.unsigned() * reader.scale
|
||||||
|
val count = reader.unsigned()
|
||||||
|
val shapes = ArrayList<MapShape>(count)
|
||||||
|
repeat(count) { shapes.add(reader.shape()) }
|
||||||
|
return MapWorld(width, height, shapes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ByteReader(private val data: ByteArray) {
|
||||||
|
|
||||||
|
private var pos = 0
|
||||||
|
|
||||||
|
/** One grid step in user units, read from the header. */
|
||||||
|
var scale = 1f
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun readHeader() {
|
||||||
|
require(
|
||||||
|
data.size > 5 &&
|
||||||
|
data[0] == 'B'.code.toByte() && data[1] == 'M'.code.toByte() &&
|
||||||
|
data[2] == 'A'.code.toByte() && data[3] == 'P'.code.toByte()
|
||||||
|
) { "not a packed map" }
|
||||||
|
require(data[4].toInt() == VERSION) { "unsupported packed map version ${data[4]}" }
|
||||||
|
pos = 5
|
||||||
|
scale = 1f / unsigned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unsigned(): Int {
|
||||||
|
var result = 0
|
||||||
|
var shift = 0
|
||||||
|
while (true) {
|
||||||
|
val byte = data[pos++].toInt()
|
||||||
|
result = result or ((byte and 0x7F) shl shift)
|
||||||
|
if (byte and 0x80 == 0) return result
|
||||||
|
shift += 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zigzag, because deltas run both ways and are almost always tiny. */
|
||||||
|
fun signed(): Int {
|
||||||
|
val value = unsigned()
|
||||||
|
return if (value and 1 == 1) -((value ushr 1) + 1) else value ushr 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flag(): Boolean = data[pos++].toInt() != 0
|
||||||
|
|
||||||
|
fun text(): String {
|
||||||
|
val length = unsigned()
|
||||||
|
val value = String(data, pos, length, Charsets.UTF_8)
|
||||||
|
pos += length
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shape(): MapShape {
|
||||||
|
val code = text()
|
||||||
|
val isState = flag()
|
||||||
|
val ringCount = unsigned()
|
||||||
|
|
||||||
|
val rings = ArrayList<FloatArray>(ringCount)
|
||||||
|
var nonZero: Path? = null
|
||||||
|
var evenOdd: Path? = null
|
||||||
|
var minX = Float.MAX_VALUE
|
||||||
|
var minY = Float.MAX_VALUE
|
||||||
|
var maxX = -Float.MAX_VALUE
|
||||||
|
var maxY = -Float.MAX_VALUE
|
||||||
|
|
||||||
|
repeat(ringCount) {
|
||||||
|
val points = unsigned()
|
||||||
|
val useEvenOdd = flag()
|
||||||
|
val ring = FloatArray(points * 2)
|
||||||
|
// Each point is a delta from the one before it, the first from the
|
||||||
|
// origin, so the whole ring decodes in one pass.
|
||||||
|
var x = 0
|
||||||
|
var y = 0
|
||||||
|
for (i in 0 until points) {
|
||||||
|
x += signed()
|
||||||
|
y += signed()
|
||||||
|
val fx = x * scale
|
||||||
|
val fy = y * scale
|
||||||
|
ring[i * 2] = fx
|
||||||
|
ring[i * 2 + 1] = fy
|
||||||
|
if (fx < minX) minX = fx
|
||||||
|
if (fx > maxX) maxX = fx
|
||||||
|
if (fy < minY) minY = fy
|
||||||
|
if (fy > maxY) maxY = fy
|
||||||
|
}
|
||||||
|
rings.add(ring)
|
||||||
|
|
||||||
|
val path = if (useEvenOdd) {
|
||||||
|
evenOdd ?: Path().also { it.fillType = Path.FillType.EVEN_ODD; evenOdd = it }
|
||||||
|
} else {
|
||||||
|
nonZero ?: Path().also { nonZero = it }
|
||||||
|
}
|
||||||
|
path.moveTo(ring[0], ring[1])
|
||||||
|
var i = 2
|
||||||
|
while (i < ring.size) {
|
||||||
|
path.lineTo(ring[i], ring[i + 1])
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
path.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return MapShape(
|
||||||
|
code, isState, rings,
|
||||||
|
RectF(minX, minY, maxX, maxY),
|
||||||
|
nonZero, evenOdd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package net.helcel.beans.map
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.graphics.PorterDuff
|
||||||
|
import android.graphics.RectF
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** Draws a [MapWorld] onto a canvas through a pan/zoom transform. */
|
||||||
|
class MapRenderer(private val map: MapWorld) {
|
||||||
|
|
||||||
|
private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
}
|
||||||
|
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
strokeJoin = Paint.Join.ROUND
|
||||||
|
}
|
||||||
|
private val inverse = Matrix()
|
||||||
|
private val values = FloatArray(9)
|
||||||
|
private val visible = RectF()
|
||||||
|
|
||||||
|
/** False when the border is too faint to be worth a draw call at all. */
|
||||||
|
private var strokeVisible = true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints the map into [canvas], mapping user units through [transform].
|
||||||
|
* Shapes outside the canvas are skipped, which is what makes drawing cheap
|
||||||
|
* once the map is zoomed in.
|
||||||
|
*/
|
||||||
|
fun draw(canvas: Canvas, transform: Matrix, style: MapStyle) {
|
||||||
|
// SRC rather than blended: the bitmap is reused between renders.
|
||||||
|
canvas.drawColor(style.background, PorterDuff.Mode.SRC)
|
||||||
|
if (!transform.invert(inverse)) return
|
||||||
|
visible.set(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat())
|
||||||
|
inverse.mapRect(visible)
|
||||||
|
|
||||||
|
// Widths are given in device pixels, so undo the zoom the canvas is
|
||||||
|
// about to apply to them, then let them grow slowly with it.
|
||||||
|
transform.getValues(values)
|
||||||
|
val scale = values[Matrix.MSCALE_X]
|
||||||
|
val perPixel = 1f / scale
|
||||||
|
val fitted = min(canvas.width / map.width, canvas.height / map.height)
|
||||||
|
val zoom = (scale / fitted).coerceAtLeast(1f)
|
||||||
|
val regionPx = strokeWidthAt(zoom, REGION_STROKE_STOPS)
|
||||||
|
val countryPx = strokeWidthAt(zoom, COUNTRY_STROKE_STOPS)
|
||||||
|
|
||||||
|
canvas.save()
|
||||||
|
canvas.concat(transform)
|
||||||
|
setStroke(style.background, if (style.regional) regionPx else countryPx, perPixel)
|
||||||
|
for ((code, color) in style.fills) {
|
||||||
|
val shape = map.byCode[code] ?: continue
|
||||||
|
if (!RectF.intersects(shape.bounds, visible)) continue
|
||||||
|
fill.color = color
|
||||||
|
drawShape(canvas, shape.nonZero, true)
|
||||||
|
drawShape(canvas, shape.evenOdd, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With regions on, the country outlines come back on top as the thicker
|
||||||
|
// borders between them.
|
||||||
|
if (style.regional) {
|
||||||
|
setStroke(style.background, countryPx, perPixel)
|
||||||
|
for (shape in map.countries) {
|
||||||
|
if (!RectF.intersects(shape.bounds, visible)) continue
|
||||||
|
drawShape(canvas, shape.nonZero, false)
|
||||||
|
drawShape(canvas, shape.evenOdd, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a border width given in device pixels.
|
||||||
|
*
|
||||||
|
* Anything thinner than a pixel cannot be drawn as such: the rasteriser
|
||||||
|
* turns it into a one pixel hairline at full strength, which is why every
|
||||||
|
* border below that width used to come out the same weight no matter how
|
||||||
|
* far out the map was. Below a pixel the width is carried as opacity
|
||||||
|
* instead, so a quarter-pixel border reads as a quarter as dark.
|
||||||
|
*/
|
||||||
|
private fun setStroke(color: Int, devicePx: Float, perPixel: Float) {
|
||||||
|
strokeVisible = devicePx > 0.004f
|
||||||
|
if (!strokeVisible) return
|
||||||
|
stroke.color = color
|
||||||
|
if (devicePx < 1f) {
|
||||||
|
stroke.strokeWidth = perPixel
|
||||||
|
stroke.alpha = (devicePx * 255f).roundToInt().coerceIn(0, 255)
|
||||||
|
} else {
|
||||||
|
stroke.strokeWidth = perPixel * devicePx
|
||||||
|
stroke.alpha = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawShape(canvas: Canvas, path: Path?, filled: Boolean) {
|
||||||
|
if (path == null) return
|
||||||
|
if (filled) canvas.drawPath(path, fill)
|
||||||
|
if (strokeVisible) canvas.drawPath(path, stroke)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package net.helcel.beans.map
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import net.helcel.beans.countries.GeoLoc
|
||||||
|
import net.helcel.beans.countries.World
|
||||||
|
import net.helcel.beans.helper.AUTO_GROUP
|
||||||
|
import net.helcel.beans.helper.Data
|
||||||
|
import net.helcel.beans.helper.NO_GROUP
|
||||||
|
import net.helcel.beans.helper.Settings
|
||||||
|
import kotlin.math.ln
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Border width in device pixels at each zoom, interpolated in between.
|
||||||
|
*
|
||||||
|
* Zoom counts how far in the map is from its fitted size, so 1 is the whole
|
||||||
|
* world on screen and 64 is as far in as it goes. The stops step by fours and
|
||||||
|
* are interpolated on a log axis, so the weight changes evenly as you pinch
|
||||||
|
* rather than in a rush at one end.
|
||||||
|
*
|
||||||
|
* Region borders start at nothing on purpose: a sub-national border says very
|
||||||
|
* little with the whole world in view, and drawing three and a half thousand of
|
||||||
|
* them only muddies the land.
|
||||||
|
*
|
||||||
|
* zoom 1x 4x 16x 64x
|
||||||
|
* region 0.0 0.5 1.0 2.0
|
||||||
|
* country 1.0 1.5 3.0 6.0
|
||||||
|
*/
|
||||||
|
val STROKE_ZOOM_STOPS = floatArrayOf(1f, 4f, 16f, 64f)
|
||||||
|
|
||||||
|
val REGION_STROKE_STOPS = floatArrayOf(0f, 0.5f, 1f, 2f)
|
||||||
|
|
||||||
|
val COUNTRY_STROKE_STOPS = floatArrayOf(1f, 1.5f, 3f, 6f)
|
||||||
|
|
||||||
|
/** The width for [zoom], straight-line between the stops on a log axis. */
|
||||||
|
fun strokeWidthAt(zoom: Float, widths: FloatArray): Float {
|
||||||
|
if (zoom <= STROKE_ZOOM_STOPS.first()) return widths.first()
|
||||||
|
for (i in 1 until STROKE_ZOOM_STOPS.size) {
|
||||||
|
if (zoom <= STROKE_ZOOM_STOPS[i]) {
|
||||||
|
val low = STROKE_ZOOM_STOPS[i - 1]
|
||||||
|
val t = ln(zoom / low) / ln(STROKE_ZOOM_STOPS[i] / low)
|
||||||
|
return widths[i - 1] + t * (widths[i] - widths[i - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return widths.last()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour every shape is drawn with, resolved once per render.
|
||||||
|
*
|
||||||
|
* This replaces the stylesheet the map used to be rendered through. A place
|
||||||
|
* inherits the colour of its parent, so painting a whole continent still shows
|
||||||
|
* up on each country inside it, and [fills] ends up holding exactly the shapes
|
||||||
|
* that get drawn — which is also exactly what a tap may land on.
|
||||||
|
*/
|
||||||
|
class MapStyle(
|
||||||
|
val regional: Boolean,
|
||||||
|
val land: Int,
|
||||||
|
val background: Int,
|
||||||
|
val fills: Map<String, Int>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun build(ctx: Context, land: Int, background: Int): MapStyle {
|
||||||
|
val regional = Settings.isRegional(ctx)
|
||||||
|
val fills = HashMap<String, Int>()
|
||||||
|
World.WWW.children.forEach { child ->
|
||||||
|
if (child.type == GeoLoc.LocType.COUNTRY) {
|
||||||
|
// A country hanging straight off the world, such as Antarctica.
|
||||||
|
addCountry(fills, child, colorOf(World.WWW, regional), regional, land)
|
||||||
|
} else {
|
||||||
|
val continent = colorOf(child, regional) ?: colorOf(World.WWW, regional)
|
||||||
|
child.children.forEach { addCountry(fills, it, continent, regional, land) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MapStyle(regional, land, background, fills)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addCountry(
|
||||||
|
fills: HashMap<String, Int>,
|
||||||
|
country: GeoLoc,
|
||||||
|
inherited: Int?,
|
||||||
|
regional: Boolean,
|
||||||
|
land: Int,
|
||||||
|
) {
|
||||||
|
val color = colorOf(country, regional) ?: inherited
|
||||||
|
// Regions are only drawn for countries that actually have some; the
|
||||||
|
// rest keep their outline filled so the map stays complete.
|
||||||
|
if (regional && country.children.isNotEmpty()) {
|
||||||
|
country.children.forEach { state ->
|
||||||
|
fills[state.code] = colorOf(state, regional) ?: color ?: land
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fills[country.code] = color ?: land
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The group colour assigned to [loc], or `null` when it has none of its own. */
|
||||||
|
private fun colorOf(loc: GeoLoc, regional: Boolean): Int? {
|
||||||
|
val key = Data.visits.getVisited(loc)
|
||||||
|
val group = Data.groups.getGroupFromKey(key)
|
||||||
|
if (group.key != NO_GROUP) return group.color.color
|
||||||
|
// A place marked automatically stands in for its children. That only
|
||||||
|
// needs a colour of its own where the children are not drawn, and
|
||||||
|
// never on a continent, which would swallow the whole map.
|
||||||
|
val isParent = loc.type == GeoLoc.LocType.GROUP ||
|
||||||
|
loc.type == GeoLoc.LocType.CUSTOM_GROUP ||
|
||||||
|
loc.type == GeoLoc.LocType.WORLD
|
||||||
|
if (key == AUTO_GROUP && !regional && !isParent) {
|
||||||
|
return Data.groups.getGroupFromPos(0).second.color.color
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
package net.helcel.beans.map
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.view.GestureDetector
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.ScaleGestureDetector
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.OverScroller
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/** A shape a tap landed on or near, with how far away it was in user units. */
|
||||||
|
class MapPick(val code: String, val distance: Float)
|
||||||
|
|
||||||
|
/** How far past the fitted map a pinch may zoom in. */
|
||||||
|
private const val MAX_ZOOM = 64f
|
||||||
|
|
||||||
|
/** How much a double tap zooms in by. */
|
||||||
|
private const val DOUBLE_TAP_ZOOM = 3f
|
||||||
|
|
||||||
|
/** Time a gesture has to settle before the map is redrawn sharp again. */
|
||||||
|
private const val RENDER_DELAY_MS = 80L
|
||||||
|
|
||||||
|
/** How long the map may stay stretched while a gesture keeps going. */
|
||||||
|
private const val MAX_STALE_MS = 500L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most candidates a single tap will offer. A wide radius over a crowded part of
|
||||||
|
* the map can reach dozens of places, which is more of a list than anyone wants
|
||||||
|
* to read; the nearest few are what the tap was plausibly aiming at.
|
||||||
|
*/
|
||||||
|
private const val MAX_CANDIDATES = 16
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The map itself: pan, pinch and tap over a [MapWorld].
|
||||||
|
*
|
||||||
|
* Drawing every country as vectors costs far too much to do on each frame, so a
|
||||||
|
* render lands in an off-screen bitmap on a worker thread and gestures just move
|
||||||
|
* that bitmap around. Once the gesture settles the map is drawn again at the new
|
||||||
|
* zoom, which is what keeps it sharp all the way in.
|
||||||
|
*/
|
||||||
|
@SuppressLint("ViewConstructor")
|
||||||
|
class MapView(context: Context) : View(context) {
|
||||||
|
|
||||||
|
var world: MapWorld? = null
|
||||||
|
set(value) {
|
||||||
|
if (field === value) return
|
||||||
|
field = value
|
||||||
|
renderer = value?.let(::MapRenderer)
|
||||||
|
fitToView()
|
||||||
|
requestRender(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var style: MapStyle? = null
|
||||||
|
set(value) {
|
||||||
|
if (field === value) return
|
||||||
|
field = value
|
||||||
|
requestRender(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far from a tap, in dp, other shapes still count as candidates. */
|
||||||
|
var touchRadiusDp: Float = 4f
|
||||||
|
|
||||||
|
/** Called on a tap with every nearby shape, nearest first. */
|
||||||
|
var onPick: ((List<MapPick>) -> Unit)? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var renderer: MapRenderer? = null
|
||||||
|
|
||||||
|
private val scroller = OverScroller(context)
|
||||||
|
|
||||||
|
private val transform = Matrix()
|
||||||
|
private val inverse = Matrix()
|
||||||
|
private val values = FloatArray(9)
|
||||||
|
private val point = FloatArray(2)
|
||||||
|
private var minScale = 1f
|
||||||
|
private var maxScale = 1f
|
||||||
|
|
||||||
|
private val blitPaint = Paint(Paint.FILTER_BITMAP_FLAG)
|
||||||
|
private val blit = Matrix()
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
private var front: Bitmap? = null
|
||||||
|
private var back: Bitmap? = null
|
||||||
|
private val frontMatrix = Matrix()
|
||||||
|
private val frontValues = FloatArray(9)
|
||||||
|
private val pendingMatrix = Matrix()
|
||||||
|
private var pendingWidth = 0
|
||||||
|
private var pendingHeight = 0
|
||||||
|
@Volatile private var lastRenderAt = 0L
|
||||||
|
|
||||||
|
private val renderThread = HandlerThread("map-render").apply { start() }
|
||||||
|
private val renderHandler = Handler(renderThread.looper)
|
||||||
|
private val renderTask = Runnable { render() }
|
||||||
|
|
||||||
|
private val scaleDetector = ScaleGestureDetector(
|
||||||
|
context,
|
||||||
|
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||||
|
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||||
|
val scale = currentScale()
|
||||||
|
val target = (scale * detector.scaleFactor).coerceIn(minScale, maxScale)
|
||||||
|
transform.postScale(target / scale, target / scale, detector.focusX, detector.focusY)
|
||||||
|
clamp()
|
||||||
|
invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private val gestureDetector = GestureDetector(
|
||||||
|
context,
|
||||||
|
object : GestureDetector.SimpleOnGestureListener() {
|
||||||
|
override fun onDown(e: MotionEvent): Boolean {
|
||||||
|
scroller.forceFinished(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFling(
|
||||||
|
e1: MotionEvent?,
|
||||||
|
e2: MotionEvent,
|
||||||
|
velocityX: Float,
|
||||||
|
velocityY: Float,
|
||||||
|
): Boolean {
|
||||||
|
val map = world ?: return false
|
||||||
|
transform.getValues(values)
|
||||||
|
val scale = values[Matrix.MSCALE_X]
|
||||||
|
val x = values[Matrix.MTRANS_X].toInt()
|
||||||
|
val y = values[Matrix.MTRANS_Y].toInt()
|
||||||
|
val w = map.width * scale
|
||||||
|
val h = map.height * scale
|
||||||
|
scroller.forceFinished(true)
|
||||||
|
scroller.fling(
|
||||||
|
x, y, velocityX.toInt(), velocityY.toInt(),
|
||||||
|
if (w > width) (width - w).toInt() else x, if (w > width) 0 else x,
|
||||||
|
if (h > height) (height - h).toInt() else y, if (h > height) 0 else y,
|
||||||
|
)
|
||||||
|
postInvalidateOnAnimation()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScroll(
|
||||||
|
e1: MotionEvent?,
|
||||||
|
e2: MotionEvent,
|
||||||
|
distanceX: Float,
|
||||||
|
distanceY: Float,
|
||||||
|
): Boolean {
|
||||||
|
transform.postTranslate(-distanceX, -distanceY)
|
||||||
|
clamp()
|
||||||
|
invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||||
|
pick(e.x, e.y)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDoubleTap(e: MotionEvent): Boolean {
|
||||||
|
val scale = currentScale()
|
||||||
|
val target = if (scale >= maxScale * 0.99f) {
|
||||||
|
minScale
|
||||||
|
} else {
|
||||||
|
min(scale * DOUBLE_TAP_ZOOM, maxScale)
|
||||||
|
}
|
||||||
|
transform.postScale(target / scale, target / scale, e.x, e.y)
|
||||||
|
clamp()
|
||||||
|
invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
scaleDetector.onTouchEvent(event)
|
||||||
|
gestureDetector.onTouchEvent(event)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun computeScroll() {
|
||||||
|
if (!scroller.computeScrollOffset()) return
|
||||||
|
transform.getValues(values)
|
||||||
|
values[Matrix.MTRANS_X] = scroller.currX.toFloat()
|
||||||
|
values[Matrix.MTRANS_Y] = scroller.currY.toFloat()
|
||||||
|
transform.setValues(values)
|
||||||
|
postInvalidateOnAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||||
|
super.onSizeChanged(w, h, oldw, oldh)
|
||||||
|
fitToView()
|
||||||
|
requestRender(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
val current = style
|
||||||
|
canvas.drawColor(current?.background ?: 0)
|
||||||
|
|
||||||
|
var stale = true
|
||||||
|
synchronized(lock) {
|
||||||
|
val bitmap = front
|
||||||
|
if (bitmap != null && !bitmap.isRecycled && frontMatrix.invert(blit)) {
|
||||||
|
blit.postConcat(transform)
|
||||||
|
canvas.drawBitmap(bitmap, blit, blitPaint)
|
||||||
|
transform.getValues(values)
|
||||||
|
stale = !values.contentEquals(frontValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Scheduling only ever happens from here or from a setter, both on the
|
||||||
|
// main thread, so a render never starts while its bitmap is on screen.
|
||||||
|
if (stale) {
|
||||||
|
val overdue = SystemClock.uptimeMillis() - lastRenderAt > MAX_STALE_MS
|
||||||
|
requestRender(if (overdue) 0L else RENDER_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
scroller.forceFinished(true)
|
||||||
|
renderHandler.removeCallbacks(renderTask)
|
||||||
|
renderThread.quitSafely()
|
||||||
|
// Dropped rather than recycled: a render may still be part way through
|
||||||
|
// one of them, and the collector is happy to take them from here.
|
||||||
|
synchronized(lock) {
|
||||||
|
front = null
|
||||||
|
back = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Centres the map in the view and works out how far it may be zoomed. */
|
||||||
|
private fun fitToView() {
|
||||||
|
val map = world ?: return
|
||||||
|
if (width == 0 || height == 0 || map.width <= 0f || map.height <= 0f) return
|
||||||
|
val scale = min(width / map.width, height / map.height)
|
||||||
|
minScale = scale
|
||||||
|
maxScale = scale * MAX_ZOOM
|
||||||
|
transform.setScale(scale, scale)
|
||||||
|
transform.postTranslate(
|
||||||
|
(width - map.width * scale) / 2f,
|
||||||
|
(height - map.height * scale) / 2f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentScale(): Float {
|
||||||
|
transform.getValues(values)
|
||||||
|
return values[Matrix.MSCALE_X]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the map from being dragged away from the viewport. */
|
||||||
|
private fun clamp() {
|
||||||
|
val map = world ?: return
|
||||||
|
transform.getValues(values)
|
||||||
|
val scale = values[Matrix.MSCALE_X]
|
||||||
|
val w = map.width * scale
|
||||||
|
val h = map.height * scale
|
||||||
|
values[Matrix.MTRANS_X] = if (w <= width) {
|
||||||
|
(width - w) / 2f
|
||||||
|
} else {
|
||||||
|
values[Matrix.MTRANS_X].coerceIn(width - w, 0f)
|
||||||
|
}
|
||||||
|
values[Matrix.MTRANS_Y] = if (h <= height) {
|
||||||
|
(height - h) / 2f
|
||||||
|
} else {
|
||||||
|
values[Matrix.MTRANS_Y].coerceIn(height - h, 0f)
|
||||||
|
}
|
||||||
|
transform.setValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestRender(delay: Long) {
|
||||||
|
if (width == 0 || height == 0 || renderer == null || style == null) return
|
||||||
|
synchronized(lock) {
|
||||||
|
pendingMatrix.set(transform)
|
||||||
|
pendingWidth = width
|
||||||
|
pendingHeight = height
|
||||||
|
}
|
||||||
|
renderHandler.removeCallbacks(renderTask)
|
||||||
|
renderHandler.postDelayed(renderTask, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun render() {
|
||||||
|
val matrix = Matrix()
|
||||||
|
val w: Int
|
||||||
|
val h: Int
|
||||||
|
synchronized(lock) {
|
||||||
|
matrix.set(pendingMatrix)
|
||||||
|
w = pendingWidth
|
||||||
|
h = pendingHeight
|
||||||
|
}
|
||||||
|
if (w <= 0 || h <= 0) return
|
||||||
|
val current = renderer ?: return
|
||||||
|
val currentStyle = style ?: return
|
||||||
|
|
||||||
|
var target = back
|
||||||
|
if (target == null || target.width != w || target.height != h) {
|
||||||
|
target?.recycle()
|
||||||
|
target = createBitmap(w, h)
|
||||||
|
back = target
|
||||||
|
}
|
||||||
|
current.draw(Canvas(target), matrix, currentStyle)
|
||||||
|
|
||||||
|
synchronized(lock) {
|
||||||
|
back = front
|
||||||
|
front = target
|
||||||
|
frontMatrix.set(matrix)
|
||||||
|
matrix.getValues(frontValues)
|
||||||
|
}
|
||||||
|
lastRenderAt = SystemClock.uptimeMillis()
|
||||||
|
postInvalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reports every shape within the tap radius of ([x], [y]), nearest first. */
|
||||||
|
private fun pick(x: Float, y: Float) {
|
||||||
|
val map = world ?: return
|
||||||
|
val current = style ?: return
|
||||||
|
val callback = onPick ?: return
|
||||||
|
if (!transform.invert(inverse)) return
|
||||||
|
|
||||||
|
point[0] = x
|
||||||
|
point[1] = y
|
||||||
|
inverse.mapPoints(point)
|
||||||
|
val radius = touchRadiusDp * resources.displayMetrics.density / currentScale()
|
||||||
|
|
||||||
|
val picks = ArrayList<MapPick>()
|
||||||
|
for (code in current.fills.keys) {
|
||||||
|
val shape = map.byCode[code] ?: continue
|
||||||
|
val bounds = shape.bounds
|
||||||
|
if (point[0] < bounds.left - radius || point[0] > bounds.right + radius) continue
|
||||||
|
if (point[1] < bounds.top - radius || point[1] > bounds.bottom + radius) continue
|
||||||
|
val distance = shape.distanceTo(point[0], point[1])
|
||||||
|
if (distance <= radius) picks.add(MapPick(code, distance))
|
||||||
|
}
|
||||||
|
if (picks.isEmpty()) return
|
||||||
|
picks.sortBy { it.distance }
|
||||||
|
callback(if (picks.size > MAX_CANDIDATES) picks.subList(0, MAX_CANDIDATES) else picks)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package net.helcel.beans.map
|
||||||
|
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.graphics.RectF
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One named piece of map geometry: a country outline or a single region.
|
||||||
|
*
|
||||||
|
* The geometry is kept twice on purpose. [nonZero] and [evenOdd] are what the
|
||||||
|
* renderer hands to the canvas, while [rings] keeps the raw points because
|
||||||
|
* hit testing needs the distance to an edge, which a [Path] cannot give us.
|
||||||
|
*/
|
||||||
|
class MapShape(
|
||||||
|
val code: String,
|
||||||
|
val isState: Boolean,
|
||||||
|
val rings: List<FloatArray>,
|
||||||
|
val bounds: RectF,
|
||||||
|
val nonZero: Path?,
|
||||||
|
val evenOdd: Path?,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** True when ([x], [y]) falls inside the shape. Holes and lakes count as outside. */
|
||||||
|
fun contains(x: Float, y: Float): Boolean {
|
||||||
|
if (x < bounds.left || x > bounds.right || y < bounds.top || y > bounds.bottom) return false
|
||||||
|
// Even-odd crossing count over every ring at once: rings never overlap, so
|
||||||
|
// an island still reads as inside and a hole still reads as outside.
|
||||||
|
var inside = false
|
||||||
|
for (ring in rings) {
|
||||||
|
var j = ring.size - 2
|
||||||
|
var i = 0
|
||||||
|
while (i < ring.size) {
|
||||||
|
val yi = ring[i + 1]
|
||||||
|
val yj = ring[j + 1]
|
||||||
|
if ((yi > y) != (yj > y)) {
|
||||||
|
val xi = ring[i]
|
||||||
|
val xj = ring[j]
|
||||||
|
if (x < xi + (y - yi) * (xj - xi) / (yj - yi)) inside = !inside
|
||||||
|
}
|
||||||
|
j = i
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inside
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distance from ([x], [y]) to the shape, `0` when the point is inside it. */
|
||||||
|
fun distanceTo(x: Float, y: Float): Float {
|
||||||
|
if (contains(x, y)) return 0f
|
||||||
|
var best = Float.MAX_VALUE
|
||||||
|
for (ring in rings) {
|
||||||
|
var j = ring.size - 2
|
||||||
|
var i = 0
|
||||||
|
while (i < ring.size) {
|
||||||
|
val d = segmentDistanceSq(x, y, ring[j], ring[j + 1], ring[i], ring[i + 1])
|
||||||
|
if (d < best) best = d
|
||||||
|
j = i
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (best == Float.MAX_VALUE) Float.MAX_VALUE else sqrt(best)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun segmentDistanceSq(
|
||||||
|
px: Float, py: Float,
|
||||||
|
ax: Float, ay: Float,
|
||||||
|
bx: Float, by: Float,
|
||||||
|
): Float {
|
||||||
|
val dx = bx - ax
|
||||||
|
val dy = by - ay
|
||||||
|
val len = dx * dx + dy * dy
|
||||||
|
val t = if (len <= 0f) 0f else (((px - ax) * dx + (py - ay) * dy) / len).coerceIn(0f, 1f)
|
||||||
|
val cx = px - (ax + t * dx)
|
||||||
|
val cy = py - (ay + t * dy)
|
||||||
|
return cx * cx + cy * cy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The whole map, in SVG user units, as a flat set of named shapes. */
|
||||||
|
class MapWorld(val width: Float, val height: Float, val shapes: List<MapShape>) {
|
||||||
|
|
||||||
|
val byCode: Map<String, MapShape> = shapes.associateBy { it.code }
|
||||||
|
|
||||||
|
/** Country outlines, keyed by ISO code. Drawn as borders when regions are on. */
|
||||||
|
val countries: List<MapShape> = shapes.filter { !it.isState }
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package net.helcel.beans.svg
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.material.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.core.graphics.drawable.toDrawable
|
|
||||||
import net.helcel.beans.countries.World
|
|
||||||
import net.helcel.beans.helper.AUTO_GROUP
|
|
||||||
import net.helcel.beans.helper.Data.groups
|
|
||||||
import net.helcel.beans.helper.Data.visits
|
|
||||||
import net.helcel.beans.helper.NO_GROUP
|
|
||||||
import net.helcel.beans.helper.Settings
|
|
||||||
import net.helcel.beans.helper.Theme.colorToHex6
|
|
||||||
|
|
||||||
|
|
||||||
class CSSWrapper(private val ctx: Context) {
|
|
||||||
|
|
||||||
private val continents: String = World.WWW.children.joinToString(",") { "#${it.code}2" }
|
|
||||||
private val countries: String = World.WWW.children.joinToString(",") { itt ->
|
|
||||||
itt.children.joinToString(",") { "#${it.code}2" }
|
|
||||||
}
|
|
||||||
private val regional: String = World.WWW.children.joinToString(",") { itt ->
|
|
||||||
itt.children.joinToString(",") { "#${it.code}1" }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun getBaseColors() : Pair<String, String> {
|
|
||||||
val colorForeground = colorToHex6(MaterialTheme.colors.onBackground.toArgb().toDrawable())
|
|
||||||
val colorBackground = colorToHex6(MaterialTheme.colors.background.toArgb().toDrawable())
|
|
||||||
|
|
||||||
return Pair(colorForeground, colorBackground)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var customCSS: String = ""
|
|
||||||
|
|
||||||
init {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun refresh() {
|
|
||||||
val id = if (Settings.isRegional(ctx)) "1" else "2"
|
|
||||||
customCSS = visits.getVisitedByValue().map { (k, v) ->
|
|
||||||
(if (groups.getGroupFromKey(k).key != NO_GROUP) {
|
|
||||||
v
|
|
||||||
} else if (!Settings.isRegional(ctx) && k == AUTO_GROUP) {
|
|
||||||
v.filter { it !in World.WWW.children.map { it1 -> it1.code } }
|
|
||||||
} else {
|
|
||||||
emptyList()
|
|
||||||
}).takeIf { it.isNotEmpty() }
|
|
||||||
?.joinToString(",") { "#${it}$id,#${it}" } + "{fill:${
|
|
||||||
if (k == AUTO_GROUP) colorToHex6(groups.getGroupFromPos(0).second.color)
|
|
||||||
else colorToHex6(groups.getGroupFromKey(k).color)
|
|
||||||
};}"
|
|
||||||
}.joinToString("")
|
|
||||||
}
|
|
||||||
@Composable
|
|
||||||
fun get(): String {
|
|
||||||
val (colorForeground,colorBackground) = getBaseColors()
|
|
||||||
refresh()
|
|
||||||
return if (Settings.isRegional(ctx)) {
|
|
||||||
val countryRegionalCSS: String =
|
|
||||||
"svg{fill:$colorForeground;stroke:$colorBackground;stroke-width:0.01;}" +
|
|
||||||
"$continents,$countries{fill:none;stroke:$colorBackground;stroke-width:0.1;}"
|
|
||||||
countryRegionalCSS + customCSS
|
|
||||||
} else {
|
|
||||||
val countryOnlyCSS: String =
|
|
||||||
"svg{fill:$colorForeground;stroke:$colorBackground;stroke-width:0.1;}" +
|
|
||||||
"${regional}{display:none;}"
|
|
||||||
countryOnlyCSS + customCSS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package net.helcel.beans.svg
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import com.caverock.androidsvg.SVG
|
|
||||||
import net.helcel.beans.R
|
|
||||||
|
|
||||||
class SVGWrapper(ctx: Context) {
|
|
||||||
|
|
||||||
private val sharedPreferences: SharedPreferences =
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(ctx)
|
|
||||||
private val svgFile = when (sharedPreferences.getString(
|
|
||||||
ctx.getString(R.string.key_projection),
|
|
||||||
ctx.getString(R.string.mercator)
|
|
||||||
)) {
|
|
||||||
ctx.getString(R.string.azimuthalequidistant) -> "aeqd01.svg"
|
|
||||||
ctx.getString(R.string.loximuthal) -> "loxim01.svg"
|
|
||||||
ctx.getString(R.string.mercator) -> "webmercator01.svg"
|
|
||||||
else -> "webmercator01.svg"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var svg: SVG? = SVG.getFromAsset(ctx.assets, svgFile)
|
|
||||||
|
|
||||||
fun get(): SVG? {
|
|
||||||
return svg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,21 +7,6 @@
|
|||||||
android:scaleY="0.8918919"
|
android:scaleY="0.8918919"
|
||||||
android:translateX="6.918919"
|
android:translateX="6.918919"
|
||||||
android:translateY="6.918919">
|
android:translateY="6.918919">
|
||||||
|
|
||||||
<group
|
|
||||||
android:scaleX="1.5"
|
|
||||||
android:scaleY="1.5"
|
|
||||||
android:translateX="10"
|
|
||||||
android:translateY="10">
|
|
||||||
<path
|
|
||||||
android:pathData="M36,36m-28,0a28,28 0,1 1,56 0a28,28 0,1 1,-56 0"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="2"
|
|
||||||
android:fillColor="@color/blue"
|
|
||||||
android:strokeColor="#000000"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
|
|
||||||
</group>
|
|
||||||
<group
|
<group
|
||||||
android:scaleX="1.25"
|
android:scaleX="1.25"
|
||||||
android:scaleY="1.25"
|
android:scaleY="1.25"
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Beans" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<!-- Status bar color in dark mode -->
|
||||||
|
<item name="android:statusBarColor">@color/blue</item>
|
||||||
|
<!-- Window background in dark mode -->
|
||||||
|
<item name="android:windowBackground">@color/darkgray</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Beans" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@color/blue</item>
|
||||||
|
<item name="android:windowBackground">@color/blue</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -13,20 +13,37 @@
|
|||||||
<string name="key_stats">Statistics</string>
|
<string name="key_stats">Statistics</string>
|
||||||
<string name="counters">Prefer counters (#)</string>
|
<string name="counters">Prefer counters (#)</string>
|
||||||
<string name="percentages">Prefer percentages (%)</string>
|
<string name="percentages">Prefer percentages (%)</string>
|
||||||
<string name="licenses">Licenses</string>
|
|
||||||
<string name="key_group">Groups</string>
|
<string name="key_group">Groups</string>
|
||||||
<string name="key_regional">Regional</string>
|
<string name="key_regional">Regional mode</string>
|
||||||
|
<string name="key_regional_desc">Enable tracking of states and provinces</string>
|
||||||
|
<string name="key_cascade_stats">key_cascade_stats</string>
|
||||||
|
<string name="pref_cascade_stats">Cascade statistics</string>
|
||||||
|
<string name="pref_cascade_stats_desc">Include parents and children in the counts</string>
|
||||||
|
<string name="key_group_desc">Enable multiple colors</string>
|
||||||
|
<string name="key_touch_radius">key_touch_radius</string>
|
||||||
|
<string name="pref_touch_radius">Tap radius</string>
|
||||||
|
<string name="pref_touch_radius_desc">How far around a tap on the map other places should be considered</string>
|
||||||
|
<string name="pref_touch_radius_value">%1$d dp</string>
|
||||||
<string name="about">About</string>
|
<string name="about">About</string>
|
||||||
|
<string name="pref_category_appearance">Appearance</string>
|
||||||
|
<string name="pref_category_features">Features</string>
|
||||||
|
<string name="pref_category_data">Data</string>
|
||||||
|
<string name="action_import">Import</string>
|
||||||
|
<string name="action_export">Export</string>
|
||||||
|
<string name="import_desc">Load data from a previously exported file.</string>
|
||||||
|
<string name="export_desc">Save your current data to a file for backup or transfer.</string>
|
||||||
|
<string name="key_projection_desc">Select how the map is projected onto a 2D surface.</string>
|
||||||
<string name="beans_is_foss">Beans is free and open source software, licensed under the GNU General Public License (version 3 or later)</string>
|
<string name="beans_is_foss">Beans is free and open source software, licensed under the GNU General Public License (version 3 or later)</string>
|
||||||
<string name="beans_repo_uri">https://github.com/helcel-net/beans</string>
|
<string name="beans_repo_uri">https://github.com/helcel-net/beans</string>
|
||||||
<string name="beans_repo">Project repository: %1$s\n Feel free to report issues or contribute.</string>
|
<string name="beans_repo">Project repository: %1$s\n Feel free to report issues or contribute.</string>
|
||||||
<string name="foss_licenses">Free and open source dependencies and licenses</string>
|
|
||||||
<string name="about_beans">About the Beans application</string>
|
<string name="about_beans">About the Beans application</string>
|
||||||
<string name="edit_group">Select the group to assign.</string>
|
<string name="edit_group">Select the group to assign.</string>
|
||||||
<string name="edit_group_sub">Long press on a group to edit its name and color.</string>
|
<string name="edit_group_sub">Long press on a group to edit its name and color.</string>
|
||||||
<string name="select_group">Select the group to keep.</string>
|
<string name="select_group">Select the group to keep.</string>
|
||||||
<string name="select_group_sub">All others will be deleted and its mappings reassigned to the group you choose here.</string>
|
<string name="select_group_sub">All others will be deleted and its mappings reassigned to the group you choose here.</string>
|
||||||
<string name="delete_group">Are your sure you want to delete this group and remove all its country mappings?</string>
|
<string name="delete_group">Are your sure you want to delete this group and remove all its country mappings?</string>
|
||||||
|
<string name="select_place">Select a place</string>
|
||||||
|
<string name="select_place_sub">Tap the place to color, or a larger one that contains it.</string>
|
||||||
<string name="delete_regions">Are you sure you want to disable regions and reassign all regional mappings to the corresponding countries?</string>
|
<string name="delete_regions">Are you sure you want to disable regions and reassign all regional mappings to the corresponding countries?</string>
|
||||||
|
|
||||||
<string name="name">Name</string>
|
<string name="name">Name</string>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Beans" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<!-- Status bar color -->
|
||||||
|
<item name="android:statusBarColor">@color/blue</item>
|
||||||
|
<!-- Window background - shows through statusBarsPadding() area -->
|
||||||
|
<item name="android:windowBackground">@color/blue</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application' version '9.2.1' apply false
|
id 'com.android.application' version '9.3.2' apply false
|
||||||
id 'com.android.library' version '9.2.1' apply false
|
id 'com.android.library' version '9.4.0' apply false
|
||||||
id 'org.jetbrains.kotlin.android' version '2.3.21' apply false
|
id 'org.jetbrains.kotlin.android' version '2.4.10' apply false
|
||||||
}
|
}
|
||||||
+2
-8
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/node
|
#!/bin/node
|
||||||
|
|
||||||
import {readFileSync, existsSync} from 'fs'
|
import {readFileSync,existsSync} from 'fs'
|
||||||
import area from '@turf/area'
|
import area from '@turf/area'
|
||||||
import * as turf from '@turf/turf'
|
import * as turf from '@turf/turf'
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ const countries =
|
|||||||
|
|
||||||
|
|
||||||
const groups = {
|
const groups = {
|
||||||
"EEE":["ALB","AND","AUT","BLR","BEL","BIH","BGR","HRV","CYP","CZE","DNK","EST","FIN","FRA","DEU","GRC","HUN","ISL","IRL","ITA","KAZ","XKO","LVA","LIE","LTU","LUX","MLT","MDA","MCO","MNE","NLD","MKD","NOR","POL","PRT","ROU","RUS","SMR","SRB","SVK","SVN","ESP","SWE","CHE","UKR","GBR","VAT","XAD"],
|
"EEE":["ALA","ALB","AND","AUT","BLR","BEL","BIH","BGR","HRV","CYP","CZE","DNK","EST","FIN","FRA","DEU","GIB","GGY","GRC","HUN","ISL","IRL","IMN","JEY","ITA","KAZ","XKO","LVA","LIE","LTU","LUX","MLT","MDA","MCO","MNE","NLD","MKD","NOR","SJM","POL","PRT","ROU","RUS","SMR","SRB","SVK","SVN","ESP","SWE","CHE","UKR","GBR","VAT","XAD"],
|
||||||
"ABB":["AFG","ARM","AZE","BHR","BGD","BTN","BRN","KHM","CHN","GEO","HKG","IND","IDN","IRN","IRQ","ISR","JPN","JOR","KWT","KGZ","LAO","LBN","MAC","MYS","MDV","MNG","MMR","NPL","PRK","OMN","PAK","PSE","PHL","QAT","SAU","SGP","KOR","LKA","SYR","TWN","TJK","THA","TLS","TUR","TKM","ARE","UZB","VNM","YEM","ZNC"],
|
"ABB":["AFG","ARM","AZE","BHR","BGD","BTN","BRN","KHM","CHN","GEO","HKG","IND","IDN","IRN","IRQ","ISR","JPN","JOR","KWT","KGZ","LAO","LBN","MAC","MYS","MDV","MNG","MMR","NPL","PRK","OMN","PAK","PSE","PHL","QAT","SAU","SGP","KOR","LKA","SYR","TWN","TJK","THA","TLS","TUR","TKM","ARE","UZB","VNM","YEM","ZNC"],
|
||||||
"FFF":["DZA","AGO","BDI","BEN","BWA","BFA","BDI","CPV","CMR","CAF","TCD","COM","COG","COD","CIV","DJI","EGY","GNQ","ERI","SWZ","ETH","GAB","GMB","GHA","GIN","GNB","KEN","LSO","LBR","LBY","MDG","MWI","MLI","MRT","MUS","MYT","MAR","MOZ","NAM","NER","NGA","COD","REU","RWA","STP","SEN","SYC","SLE","SOM","ZAF","SSD","SHN","SDN","TZA","TGO","TUN","UGA","COD","ZMB","ZWE","ESH"],
|
"FFF":["DZA","AGO","BDI","BEN","BWA","BFA","BDI","CPV","CMR","CAF","TCD","COM","COG","COD","CIV","DJI","EGY","GNQ","ERI","SWZ","ETH","GAB","GMB","GHA","GIN","GNB","KEN","LSO","LBR","LBY","MDG","MWI","MLI","MRT","MUS","MYT","MAR","MOZ","NAM","NER","NGA","COD","REU","RWA","STP","SEN","SYC","SLE","SOM","ZAF","SSD","SHN","SDN","TZA","TGO","TUN","UGA","COD","ZMB","ZWE","ESH"],
|
||||||
"NNN":["ABW","AIA","ATG","BHS","BRB","BLZ","BMU","VGB","CAN","CYM","CRI","CUB","CUW","DMA","DOM","SLV","GRL","GRD","GLP","GTM","HTI","HND","JAM","MTQ","MEX","MSR","ANT","CUW","NIC","PAN","PRI","KNA","LCA","MAF","SPM","VCT","TTO","TCA","USA","XCL"],
|
"NNN":["ABW","AIA","ATG","BHS","BRB","BLZ","BMU","VGB","CAN","CYM","CRI","CUB","CUW","DMA","DOM","SLV","GRL","GRD","GLP","GTM","HTI","HND","JAM","MTQ","MEX","MSR","ANT","CUW","NIC","PAN","PRI","KNA","LCA","MAF","SPM","VCT","TTO","TCA","USA","XCL"],
|
||||||
@@ -32,7 +32,6 @@ const groups = {
|
|||||||
"UUU":["ASM","AUS","COK","FJI","PYF","GUM","KIR","MHL","FSM","NRU","NCL","NZL","NIU","NFK","MNP","PLW","PNG","PCN","SLB","TKL","TON","TUV","VUT","WLF"],
|
"UUU":["ASM","AUS","COK","FJI","PYF","GUM","KIR","MHL","FSM","NRU","NCL","NZL","NIU","NFK","MNP","PLW","PNG","PCN","SLB","TKL","TON","TUV","VUT","WLF"],
|
||||||
"XXX":[
|
"XXX":[
|
||||||
"ATA", // Antarctica: not in any other region
|
"ATA", // Antarctica: not in any other region
|
||||||
"ALA",// Åland Islands: an autonomous region of Finland, but not a member of the EU or UN
|
|
||||||
"BES",// Bonaire, Sint Eustatius and Saba: special municipalities of the Netherlands in the Caribbean
|
"BES",// Bonaire, Sint Eustatius and Saba: special municipalities of the Netherlands in the Caribbean
|
||||||
"BVT",// Bouvet Island: an uninhabited territory of Norway in the South Atlantic
|
"BVT",// Bouvet Island: an uninhabited territory of Norway in the South Atlantic
|
||||||
"IOT",// British Indian Ocean Territory: a British overseas territory in the Indian Ocean
|
"IOT",// British Indian Ocean Territory: a British overseas territory in the Indian Ocean
|
||||||
@@ -40,16 +39,11 @@ const groups = {
|
|||||||
"CCK",// Cocos (Keeling) Islands: an Australian external territory in the Indian Ocean
|
"CCK",// Cocos (Keeling) Islands: an Australian external territory in the Indian Ocean
|
||||||
"FRO",// Faroe Islands: an autonomous region of Denmark
|
"FRO",// Faroe Islands: an autonomous region of Denmark
|
||||||
"ATF",// French Southern and Antarctic Lands: a territory of France located in the southern Indian Ocean
|
"ATF",// French Southern and Antarctic Lands: a territory of France located in the southern Indian Ocean
|
||||||
"GIB",// Gibraltar: a British overseas territory located at the southern tip of the Iberian Peninsula
|
|
||||||
"GGY",// Guernsey: a British Crown dependency in the English Channel
|
|
||||||
"HMD",// Heard Island and McDonald Islands: an uninhabited Australian external territory in the southern Indian Ocean
|
"HMD",// Heard Island and McDonald Islands: an uninhabited Australian external territory in the southern Indian Ocean
|
||||||
"IMN",// Isle of Man: a British Crown dependency located in the Irish Sea
|
|
||||||
"JEY",// Jersey: a British Crown dependency located in the English Channel
|
|
||||||
"BLM",// Saint Barthélemy: an overseas collectivity of France in the Caribbean
|
"BLM",// Saint Barthélemy: an overseas collectivity of France in the Caribbean
|
||||||
"WSM",// Samoa: an independent island nation in the South Pacific
|
"WSM",// Samoa: an independent island nation in the South Pacific
|
||||||
"SXM",// Sint Maarten: a constituent country of the Kingdom of the Netherlands in the Caribbean
|
"SXM",// Sint Maarten: a constituent country of the Kingdom of the Netherlands in the Caribbean
|
||||||
"SGS",// South Georgia and the South Sandwich Islands: a British overseas territory in the southern Atlantic Ocean
|
"SGS",// South Georgia and the South Sandwich Islands: a British overseas territory in the southern Atlantic Ocean
|
||||||
"SJM",// Svalbard and Jan Mayen: an archipelago administered by Norway
|
|
||||||
"UMI",// United States Minor Outlying Islands: a collection of nine insular areas of the United States
|
"UMI",// United States Minor Outlying Islands: a collection of nine insular areas of the United States
|
||||||
"VIR",// United States Virgin Islands: an unincorporated territory of the United States in the Caribbean
|
"VIR",// United States Virgin Islands: an unincorporated territory of the United States in the Caribbean
|
||||||
]
|
]
|
||||||
|
|||||||
+48
-43
@@ -1,17 +1,16 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
LOCAL_SVG_PATH="app/src/main/assets/"
|
|
||||||
|
|
||||||
GADM_VERSION="4.1"
|
GADM_VERSION="4.1"
|
||||||
GADM_BASEPATH="https://geodata.ucdavis.edu/gadm"
|
GADM_BASEPATH="https://geodata.ucdavis.edu/gadm"
|
||||||
|
|
||||||
mapshaper="./node_modules/mapshaper/bin/mapshaper"
|
mapshaper="./node_modules/mapshaper/bin/mapshaper"
|
||||||
ATA_URL="https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/905b0baf5f4fb3b9ccf45293647dcacdb2b799d4/releaseData/gbOpen/ATA/ADM0/geoBoundaries-ATA-ADM0_simplified.geojson"
|
ATA_URL="https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/905b0baf5f4fb3b9ccf45293647dcacdb2b799d4/releaseData/gbOpen/ATA/ADM0/geoBoundaries-ATA-ADM0_simplified.geojson"
|
||||||
|
|
||||||
|
# Caspian Sea: "XCA"
|
||||||
countries=(
|
countries=(
|
||||||
"AFG" "XAD" "ALA" "ALB" "DZA" "ASM" "AND" "AGO" "AIA" "ATG" "ARG" "ARM" "ABW" "AUS" "AUT" "AZE"
|
"AFG" "XAD" "ALA" "ALB" "DZA" "ASM" "AND" "AGO" "AIA" "ATG" "ARG" "ARM" "ABW" "AUS" "AUT" "AZE"
|
||||||
"BHS" "BHR" "BGD" "BRB" "BLR" "BEL" "BLZ" "BEN" "BMU" "BTN" "BOL" "BES" "BIH" "BWA" "BVT" "BRA" "IOT" "VGB" "BRN" "BGR" "BFA" "BDI" "KHM"
|
"BHS" "BHR" "BGD" "BRB" "BLR" "BEL" "BLZ" "BEN" "BMU" "BTN" "BOL" "BES" "BIH" "BWA" "BVT" "BRA" "IOT" "VGB" "BRN" "BGR" "BFA" "BDI" "KHM"
|
||||||
"CMR" "CAN" "CPV" "XCA" "CYM" "CAF" "TCD" "CHL" "CHN" "CXR" "XCL" "CCK" "COL" "COM" "COK" "CRI" "CIV" "HRV" "CUB" "CUW" "CYP" "CZE" "COD"
|
"CMR" "CAN" "CPV" "CYM" "CAF" "TCD" "CHL" "CHN" "CXR" "XCL" "CCK" "COL" "COM" "COK" "CRI" "CIV" "HRV" "CUB" "CUW" "CYP" "CZE" "COD"
|
||||||
"DNK" "DJI" "DMA" "DOM" "ECU" "EGY" "SLV" "GNQ" "ERI" "EST" "ETH" "FLK" "FRO" "FJI" "FIN" "FRA" "GUF" "PYF" "ATF"
|
"DNK" "DJI" "DMA" "DOM" "ECU" "EGY" "SLV" "GNQ" "ERI" "EST" "ETH" "FLK" "FRO" "FJI" "FIN" "FRA" "GUF" "PYF" "ATF"
|
||||||
"GAB" "GMB" "GEO" "DEU" "GHA" "GIB" "GRC" "GRL" "GRD" "GLP" "GUM" "GTM" "GGY" "GIN" "GNB" "GUY" "HTI" "HMD" "HND" "HUN"
|
"GAB" "GMB" "GEO" "DEU" "GHA" "GIB" "GRC" "GRL" "GRD" "GLP" "GUM" "GTM" "GGY" "GIN" "GNB" "GUY" "HTI" "HMD" "HND" "HUN"
|
||||||
"ISL" "IND" "IDN" "IRN" "IRQ" "IRL" "IMN" "ISR" "ITA" "JAM" "JPN" "JEY" "JOR" "KAZ" "KEN" "KIR" "XKO" "KWT" "KGZ"
|
"ISL" "IND" "IDN" "IRN" "IRQ" "IRL" "IMN" "ISR" "ITA" "JAM" "JPN" "JEY" "JOR" "KAZ" "KEN" "KIR" "XKO" "KWT" "KGZ"
|
||||||
@@ -79,44 +78,47 @@ download_1() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
toSVG_0() {
|
generate_map() {
|
||||||
local input_files=("ATA")
|
local OUT_FILE="$1" # First argument: Output destination path
|
||||||
|
local PROJ_ARGS="$2" # Second argument: Projection parameters
|
||||||
|
shift 2 # Remove the first two arguments, leaving only the files
|
||||||
|
local FILES_TO_RUN=("$@") # Capture all remaining arguments as the file array
|
||||||
|
|
||||||
for country in "${countries[@]}"
|
echo "Generating: $OUT_FILE using projection [$PROJ_ARGS]"
|
||||||
do
|
echo "Processing ${#FILES_TO_RUN[@]} files..."
|
||||||
input_file="./temp/0/${country}.json"
|
|
||||||
if [ -f "$input_file" ]; then
|
|
||||||
input_files+=("$input_file")
|
|
||||||
else
|
|
||||||
echo "Input file $input_file not found."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
|
# Each feature is tagged with the place it belongs to and whether it is a
|
||||||
|
# country outline or one of its regions. packmap.js groups on that; nothing
|
||||||
|
# downstream needs the features split into layers.
|
||||||
|
local JS_PIPELINE_LOGIC="
|
||||||
|
const conflicts = {
|
||||||
|
'Z01': 'IND', 'Z04': 'IND', 'Z05': 'IND', 'Z07': 'IND', 'Z09': 'IND',
|
||||||
|
'Z02': 'CHN', 'Z03': 'CHN','Z08': 'CHN',
|
||||||
|
'Z06': 'PAK'
|
||||||
|
};
|
||||||
|
let rawCode = GID_0 || 'UNK';
|
||||||
|
let cCode = conflicts[rawCode] ? conflicts[rawCode] : rawCode;
|
||||||
|
// Antarctica's boundary file carries no country code at either level.
|
||||||
|
if (cCode === 'UNK') cCode = 'ATA';
|
||||||
|
let isGidMissing = (!GID || GID === 'undefined' || GID === 'null' || GID === '');
|
||||||
|
CODE = isGidMissing ? cCode : GID;
|
||||||
|
LEVEL = isGidMissing ? 0 : 1;
|
||||||
|
"
|
||||||
|
|
||||||
|
"$mapshaper" -i "${FILES_TO_RUN[@]}" combine-files \
|
||||||
|
-snap \
|
||||||
|
-merge-layers force\
|
||||||
|
-proj $PROJ_ARGS densify \
|
||||||
|
-simplify 10% weighted keep-shapes \
|
||||||
|
-filter-islands min-area=0 \
|
||||||
|
-sort 'this.area' ascending \
|
||||||
|
-each "$JS_PIPELINE_LOGIC" \
|
||||||
|
-o "$OUT_FILE" \
|
||||||
|
format=geojson
|
||||||
|
|
||||||
"$mapshaper" -i combine-files ${input_files[@]} -proj webmercator -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/mercator0.svg svg-data=GID_0,COUNTRY id-field=GID_0
|
|
||||||
"$mapshaper" -i combine-files ${input_files[@]} -proj aeqd +lat_0=90 -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/aeqd0.svg svg-data=GID_0,COUNTRY id-field=GID_0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toSVG_1() {
|
build_maps() {
|
||||||
input_files=("ATA")
|
|
||||||
|
|
||||||
for country in "${countries[@]}"
|
|
||||||
do
|
|
||||||
input_file="./temp/1/${country}.json"
|
|
||||||
# input_file="./temp/1/gadm41_${country}_1.json"
|
|
||||||
if [ -f "$input_file" ]; then
|
|
||||||
input_files+=("$input_file")
|
|
||||||
else
|
|
||||||
echo "Input file $input_file not found."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
"$mapshaper" -i combine-files ${input_files[@]} -proj webmercator -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/mercator1.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
|
|
||||||
"$mapshaper" -i combine-files ${input_files[@]} -proj aeqd +lat_0=90 -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/aeqd1.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
toSVG_01() {
|
|
||||||
input_files=()
|
input_files=()
|
||||||
|
|
||||||
input_files+=("./temp/1/ATA.json")
|
input_files+=("./temp/1/ATA.json")
|
||||||
@@ -136,9 +138,12 @@ toSVG_01() {
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
"$mapshaper" -i combine-files ${input_files[@]} snap -proj loxim densify -simplify 0.001 weighted keep-shapes -o ./app/src/main/assets/loxim01.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
|
generate_map "./mapdata/loxim01.geojson" "loxim" "${input_files[@]}"
|
||||||
"$mapshaper" -i combine-files ${input_files[@]} snap -proj webmercator densify -simplify 0.001 weighted keep-shapes -o ./app/src/main/assets/webmercator01.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
|
generate_map "./mapdata/webmercator01.geojson" "webmercator" "${input_files[@]}"
|
||||||
"$mapshaper" -i combine-files ${input_files[@]} snap -proj aeqd +lat_0=90 densify -simplify 0.001 weighted keep-shapes -o ./app/src/main/assets/aeqd01.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
|
generate_map "./mapdata/aeqd01.geojson" "aeqd +lat_0=90" "${input_files[@]}"
|
||||||
|
|
||||||
|
# The app reads the packed form; the GeoJSON above is only an intermediate.
|
||||||
|
node packmap.js ./mapdata/loxim01.geojson ./mapdata/webmercator01.geojson ./mapdata/aeqd01.geojson
|
||||||
}
|
}
|
||||||
|
|
||||||
do_1() {
|
do_1() {
|
||||||
@@ -153,10 +158,10 @@ do_0() {
|
|||||||
do
|
do
|
||||||
download_0 "$country"
|
download_0 "$country"
|
||||||
done
|
done
|
||||||
wget -q -O "./temp/1/ATA.json" "$ATA_URL"
|
wget -q -O "./temp/0/ATA.json" "$ATA_URL"
|
||||||
}
|
}
|
||||||
# do_0
|
# do_0
|
||||||
# do_1
|
# do_1
|
||||||
# toSVG_0
|
build_maps
|
||||||
# toSVG_1
|
|
||||||
toSVG_01
|
#CUW, CCK, XCL, CXR, IOT, BVT, ABW, FLK, GIB, HMD, KIR, SXM, MDV, MCO, NIU, NFK, PCN, MAF, SGS, VAT
|
||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
retries=0
|
retries=0
|
||||||
retryBackOffMs=500
|
retryBackOffMs=500
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
#
|
#
|
||||||
# Gradle start up script for POSIX generated by Gradle.
|
# gradlew start up script for POSIX generated by Gradle.
|
||||||
#
|
#
|
||||||
# Important for running:
|
# Important for running:
|
||||||
#
|
#
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
# bash, then to run this script, type that shell name before the whole
|
# bash, then to run this script, type that shell name before the whole
|
||||||
# command line, like:
|
# command line, like:
|
||||||
#
|
#
|
||||||
# ksh Gradle
|
# ksh gradlew
|
||||||
#
|
#
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
# requires all of these POSIX shell features:
|
# requires all of these POSIX shell features:
|
||||||
|
|||||||
Vendored
+2
-2
@@ -19,7 +19,7 @@
|
|||||||
@if "%DEBUG%"=="" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem Gradle startup script for Windows
|
@rem gradlew startup script for Windows
|
||||||
@rem
|
@rem
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute gradlew
|
||||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
@rem which allows us to clear the local environment before executing the java command
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|||||||
-325
@@ -1,325 +0,0 @@
|
|||||||
var continents = {
|
|
||||||
XAD:"EEE",
|
|
||||||
ALA:"EEE",
|
|
||||||
ALB:"EEE",
|
|
||||||
AND:"EEE",
|
|
||||||
AUT:"EEE",
|
|
||||||
BLR:"EEE",
|
|
||||||
BEL:"EEE",
|
|
||||||
BIH:"EEE",
|
|
||||||
BGR:"EEE",
|
|
||||||
HRV:"EEE",
|
|
||||||
CYP:"EEE",
|
|
||||||
CZE:"EEE",
|
|
||||||
DNK:"EEE",
|
|
||||||
EST:"EEE",
|
|
||||||
FRO:"EEE",
|
|
||||||
FIN:"EEE",
|
|
||||||
FRA:"EEE",
|
|
||||||
DEU:"EEE",
|
|
||||||
GIB:"EEE",
|
|
||||||
GRC:"EEE",
|
|
||||||
GGY:"EEE",
|
|
||||||
HUN:"EEE",
|
|
||||||
ISL:"EEE",
|
|
||||||
IRL:"EEE",
|
|
||||||
IMN:"EEE",
|
|
||||||
ITA:"EEE",
|
|
||||||
JEY:"EEE",
|
|
||||||
KAZ:"EEE",
|
|
||||||
XKO:"EEE",
|
|
||||||
LVA:"EEE",
|
|
||||||
LIE:"EEE",
|
|
||||||
LTU:"EEE",
|
|
||||||
LUX:"EEE",
|
|
||||||
MLT:"EEE",
|
|
||||||
MDA:"EEE",
|
|
||||||
MCO:"EEE",
|
|
||||||
MNE:"EEE",
|
|
||||||
NLD:"EEE",
|
|
||||||
MKD:"EEE",
|
|
||||||
NOR:"EEE",
|
|
||||||
POL:"EEE",
|
|
||||||
PRT:"EEE",
|
|
||||||
ROU:"EEE",
|
|
||||||
RUS:"EEE",
|
|
||||||
SMR:"EEE",
|
|
||||||
SRB:"EEE",
|
|
||||||
SVK:"EEE",
|
|
||||||
SVN:"EEE",
|
|
||||||
ESP:"EEE",
|
|
||||||
SJM:"EEE",
|
|
||||||
SWE:"EEE",
|
|
||||||
CHE:"EEE",
|
|
||||||
UKR:"EEE",
|
|
||||||
GBR:"EEE",
|
|
||||||
VAT:"EEE",
|
|
||||||
|
|
||||||
AFG:"ABB",
|
|
||||||
ARM:"ABB",
|
|
||||||
AZE:"ABB",
|
|
||||||
BHR:"ABB",
|
|
||||||
BGD:"ABB",
|
|
||||||
BTN:"ABB",
|
|
||||||
IOT:"ABB",
|
|
||||||
BRN:"ABB",
|
|
||||||
KHM:"ABB",
|
|
||||||
CCK:"ABB",
|
|
||||||
CHN:"ABB",
|
|
||||||
CXR:"ABB",
|
|
||||||
GEO:"ABB",
|
|
||||||
HKG:"ABB",
|
|
||||||
IND:"ABB",
|
|
||||||
IDN:"ABB",
|
|
||||||
IRN:"ABB",
|
|
||||||
IRQ:"ABB",
|
|
||||||
ISR:"ABB",
|
|
||||||
JPN:"ABB",
|
|
||||||
JOR:"ABB",
|
|
||||||
KWT:"ABB",
|
|
||||||
KGZ:"ABB",
|
|
||||||
LAO:"ABB",
|
|
||||||
LBN:"ABB",
|
|
||||||
MAC:"ABB",
|
|
||||||
MYS:"ABB",
|
|
||||||
MDV:"ABB",
|
|
||||||
MNG:"ABB",
|
|
||||||
MMR:"ABB",
|
|
||||||
NPL:"ABB",
|
|
||||||
PRK:"ABB",
|
|
||||||
OMN:"ABB",
|
|
||||||
PAK:"ABB",
|
|
||||||
PSE:"ABB",
|
|
||||||
PHL:"ABB",
|
|
||||||
QAT:"ABB",
|
|
||||||
SAU:"ABB",
|
|
||||||
SGP:"ABB",
|
|
||||||
KOR:"ABB",
|
|
||||||
LKA:"ABB",
|
|
||||||
SYR:"ABB",
|
|
||||||
TWN:"ABB",
|
|
||||||
TJK:"ABB",
|
|
||||||
THA:"ABB",
|
|
||||||
TLS:"ABB",
|
|
||||||
TUR:"ABB",
|
|
||||||
TKM:"ABB",
|
|
||||||
ARE:"ABB",
|
|
||||||
UZB:"ABB",
|
|
||||||
VNM:"ABB",
|
|
||||||
YEM:"ABB",
|
|
||||||
ZNC:"ABB",
|
|
||||||
|
|
||||||
DZA:"FFF",
|
|
||||||
AGO:"FFF",
|
|
||||||
BDI:"FFF",
|
|
||||||
BEN:"FFF",
|
|
||||||
BWA:"FFF",
|
|
||||||
BFA:"FFF",
|
|
||||||
BDI:"FFF",
|
|
||||||
CPV:"FFF",
|
|
||||||
CMR:"FFF",
|
|
||||||
CAF:"FFF",
|
|
||||||
TCD:"FFF",
|
|
||||||
COM:"FFF",
|
|
||||||
COG:"FFF",
|
|
||||||
COD:"FFF",
|
|
||||||
CIV:"FFF",
|
|
||||||
DJI:"FFF",
|
|
||||||
EGY:"FFF",
|
|
||||||
GNQ:"FFF",
|
|
||||||
ERI:"FFF",
|
|
||||||
ETH:"FFF",
|
|
||||||
ATF:"FFF",
|
|
||||||
GAB:"FFF",
|
|
||||||
GMB:"FFF",
|
|
||||||
GHA:"FFF",
|
|
||||||
GIN:"FFF",
|
|
||||||
GNB:"FFF",
|
|
||||||
KEN:"FFF",
|
|
||||||
LSO:"FFF",
|
|
||||||
LBR:"FFF",
|
|
||||||
LBY:"FFF",
|
|
||||||
MDG:"FFF",
|
|
||||||
MWI:"FFF",
|
|
||||||
MLI:"FFF",
|
|
||||||
MRT:"FFF",
|
|
||||||
MUS:"FFF",
|
|
||||||
MYT:"FFF",
|
|
||||||
MAR:"FFF",
|
|
||||||
MOZ:"FFF",
|
|
||||||
NAM:"FFF",
|
|
||||||
NER:"FFF",
|
|
||||||
NGA:"FFF",
|
|
||||||
COD:"FFF",
|
|
||||||
REU:"FFF",
|
|
||||||
RWA:"FFF",
|
|
||||||
STP:"FFF",
|
|
||||||
SEN:"FFF",
|
|
||||||
SYC:"FFF",
|
|
||||||
SLE:"FFF",
|
|
||||||
SOM:"FFF",
|
|
||||||
ZAF:"FFF",
|
|
||||||
SSD:"FFF",
|
|
||||||
SHN:"FFF",
|
|
||||||
SDN:"FFF",
|
|
||||||
SWZ:"FFF",
|
|
||||||
TZA:"FFF",
|
|
||||||
TGO:"FFF",
|
|
||||||
TUN:"FFF",
|
|
||||||
UGA:"FFF",
|
|
||||||
COD:"FFF",
|
|
||||||
ZMB:"FFF",
|
|
||||||
ZWE:"FFF",
|
|
||||||
ESH:"FFF",
|
|
||||||
|
|
||||||
ABW:"NNN",
|
|
||||||
AIA:"NNN",
|
|
||||||
ATG:"NNN",
|
|
||||||
BHS:"NNN",
|
|
||||||
BRB:"NNN",
|
|
||||||
BLZ:"NNN",
|
|
||||||
BMU:"NNN",
|
|
||||||
BES:"NNN",
|
|
||||||
VGB:"NNN",
|
|
||||||
CAN:"NNN",
|
|
||||||
CYM:"NNN",
|
|
||||||
XCL:"NNN",
|
|
||||||
CRI:"NNN",
|
|
||||||
CUB:"NNN",
|
|
||||||
CUW:"NNN",
|
|
||||||
DMA:"NNN",
|
|
||||||
DOM:"NNN",
|
|
||||||
SLV:"NNN",
|
|
||||||
GRL:"NNN",
|
|
||||||
GRD:"NNN",
|
|
||||||
GLP:"NNN",
|
|
||||||
GTM:"NNN",
|
|
||||||
HTI:"NNN",
|
|
||||||
HND:"NNN",
|
|
||||||
JAM:"NNN",
|
|
||||||
MTQ:"NNN",
|
|
||||||
MEX:"NNN",
|
|
||||||
MSR:"NNN",
|
|
||||||
ANT:"NNN",
|
|
||||||
CUW:"NNN",
|
|
||||||
NIC:"NNN",
|
|
||||||
PAN:"NNN",
|
|
||||||
PRI:"NNN",
|
|
||||||
BLM:"NNN",
|
|
||||||
KNA:"NNN",
|
|
||||||
LCA:"NNN",
|
|
||||||
MAF:"NNN",
|
|
||||||
SPM:"NNN",
|
|
||||||
VCT:"NNN",
|
|
||||||
SXM:"NNN",
|
|
||||||
TTO:"NNN",
|
|
||||||
TCA:"NNN",
|
|
||||||
USA:"NNN",
|
|
||||||
UMI:"NNN",
|
|
||||||
VIR:"NNN",
|
|
||||||
|
|
||||||
ARG:"SRR",
|
|
||||||
BOL:"SRR",
|
|
||||||
BRA:"SRR",
|
|
||||||
CHL:"SRR",
|
|
||||||
COL:"SRR",
|
|
||||||
ECU:"SRR",
|
|
||||||
FLK:"SRR",
|
|
||||||
GUF:"SRR",
|
|
||||||
GUY:"SRR",
|
|
||||||
PRY:"SRR",
|
|
||||||
PER:"SRR",
|
|
||||||
SUR:"SRR",
|
|
||||||
URY:"SRR",
|
|
||||||
VEN:"SRR",
|
|
||||||
|
|
||||||
ASM:"UUU",
|
|
||||||
AUS:"UUU",
|
|
||||||
COK:"UUU",
|
|
||||||
FJI:"UUU",
|
|
||||||
PYF:"UUU",
|
|
||||||
GUM:"UUU",
|
|
||||||
KIR:"UUU",
|
|
||||||
MHL:"UUU",
|
|
||||||
FSM:"UUU",
|
|
||||||
NRU:"UUU",
|
|
||||||
NCL:"UUU",
|
|
||||||
NZL:"UUU",
|
|
||||||
NIU:"UUU",
|
|
||||||
NFK:"UUU",
|
|
||||||
MNP:"UUU",
|
|
||||||
PLW:"UUU",
|
|
||||||
PNG:"UUU",
|
|
||||||
PCN:"UUU",
|
|
||||||
WSM:"UUU",
|
|
||||||
SLB:"UUU",
|
|
||||||
TKL:"UUU",
|
|
||||||
TON:"UUU",
|
|
||||||
TUV:"UUU",
|
|
||||||
VUT:"UUU",
|
|
||||||
WLF:"UUU",
|
|
||||||
|
|
||||||
ATA:"XXX",
|
|
||||||
BVT:"XXX",
|
|
||||||
HMD:"XXX",
|
|
||||||
SGS:"XXX",
|
|
||||||
}
|
|
||||||
|
|
||||||
import fs from "fs"
|
|
||||||
import {JSDOM} from "jsdom"
|
|
||||||
|
|
||||||
function groupByContinent(svgFilePath) {
|
|
||||||
// Read the SVG file
|
|
||||||
fs.readFile(svgFilePath, 'utf-8', (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error reading SVG file:', err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a virtual DOM with JSDOM
|
|
||||||
const dom = new JSDOM(data);
|
|
||||||
const document = dom.window.document;
|
|
||||||
|
|
||||||
// Select the root <svg> element
|
|
||||||
const svgElement = document.querySelector('svg');
|
|
||||||
|
|
||||||
// Group <g> elements representing countries by continent
|
|
||||||
const world = {}; // Renamed continents object to 'world'
|
|
||||||
svgElement.querySelectorAll('g').forEach(countryGroup => {
|
|
||||||
const countryId = countryGroup.getAttribute('id');
|
|
||||||
if (countryId!= null){
|
|
||||||
const continentId = continents[countryId.replace(/\d/g, "")] || "XXX"; // Remove numbers from country ID to get continent ID
|
|
||||||
if (!world[continentId]) {
|
|
||||||
world[continentId] = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
||||||
world[continentId].setAttribute('id', continentId);
|
|
||||||
}
|
|
||||||
world[continentId].innerHTML += countryGroup.outerHTML;
|
|
||||||
}else{
|
|
||||||
|
|
||||||
console.log(countryId,countryGroup.outerHTML)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create new <g> elements for each continent
|
|
||||||
svgElement.innerHTML = '';
|
|
||||||
for (const continentId in world) {
|
|
||||||
svgElement.appendChild(world[continentId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output the modified SVG
|
|
||||||
fs.writeFile(svgFilePath, svgElement.outerHTML, 'utf-8', err => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error writing to SVG file:', err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('SVG file updated successfully.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Example usage:
|
|
||||||
const svgFileBasePath = "./app/src/main/assets/" ;
|
|
||||||
groupByContinent(svgFileBasePath+ "webmercator01.svg");
|
|
||||||
groupByContinent(svgFileBasePath+ "aeqd01.svg");
|
|
||||||
groupByContinent(svgFileBasePath+ "loxim01.svg");
|
|
||||||
+4
-2
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"scripts": {
|
||||||
|
"pack": "node packmap.js mapdata/loxim01.geojson mapdata/webmercator01.geojson mapdata/aeqd01.geojson"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@turf/area": "^7.3.5",
|
"@turf/area": "^7.3.5",
|
||||||
"@turf/turf": "^7.3.5",
|
"@turf/turf": "^7.3.5",
|
||||||
"jsdom": "^29.1.1",
|
"mapshaper": "^0.7.22"
|
||||||
"mapshaper": "^0.7.19"
|
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
#!/bin/node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packs the generated map data into the compact binary the app reads.
|
||||||
|
*
|
||||||
|
* The app used to ship SVG, which spends about four megabytes per projection
|
||||||
|
* spelling coordinates out in decimal ASCII inside XML, and needed the map
|
||||||
|
* parsed as a document at startup. Nothing needs a document: the app wants a
|
||||||
|
* list of places and the outlines that belong to them, which is what this
|
||||||
|
* writes.
|
||||||
|
*
|
||||||
|
* Input is mapshaper's projected GeoJSON, where each feature carries:
|
||||||
|
* CODE the place it belongs to, e.g. "FRA" or "FRA_CE"
|
||||||
|
* LEVEL 0 for a country outline, 1 for one of its regions
|
||||||
|
*
|
||||||
|
* Output format. All integers are LEB128 varints and coordinates are in grid
|
||||||
|
* steps, on a canvas MAP_WIDTH user units across:
|
||||||
|
*
|
||||||
|
* "BMAP" 0x02
|
||||||
|
* precision uvarint, grid steps per user unit
|
||||||
|
* width, height uvarint, in grid steps
|
||||||
|
* shapeCount uvarint
|
||||||
|
* per shape:
|
||||||
|
* codeLength, code uvarint + UTF-8 bytes
|
||||||
|
* flags 1 byte, bit 0 = is a region
|
||||||
|
* ringCount uvarint
|
||||||
|
* per ring:
|
||||||
|
* pointCount uvarint
|
||||||
|
* flags 1 byte, bit 0 = even-odd fill
|
||||||
|
* pointCount pairs of dx, dy zigzag varint, from the previous point,
|
||||||
|
* starting from the origin on each ring
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from 'fs'
|
||||||
|
|
||||||
|
/** Canvas width in user units. Stroke widths in the app are relative to it. */
|
||||||
|
const MAP_WIDTH = 800
|
||||||
|
|
||||||
|
/** Blank edge left around the map, as mapshaper's own SVG output used to. */
|
||||||
|
const MARGIN = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid steps per user unit. Coordinates are snapped to this, so it sets how
|
||||||
|
* fine the map can be; it is written into the header rather than agreed by
|
||||||
|
* convention, so raising it only means regenerating the assets.
|
||||||
|
*/
|
||||||
|
const PRECISION = 20
|
||||||
|
|
||||||
|
function uvarint(bytes, value) {
|
||||||
|
while (value >= 0x80) {
|
||||||
|
bytes.push((value & 0x7f) | 0x80)
|
||||||
|
value >>>= 7
|
||||||
|
}
|
||||||
|
bytes.push(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function svarint(bytes, value) {
|
||||||
|
uvarint(bytes, value < 0 ? -value * 2 - 1 : value * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every polygon of a feature, outer rings and holes alike. */
|
||||||
|
function ringsOf(geometry) {
|
||||||
|
if (!geometry) return []
|
||||||
|
if (geometry.type === 'Polygon') return [geometry.coordinates]
|
||||||
|
if (geometry.type === 'MultiPolygon') return geometry.coordinates
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups features by the place they belong to. A country outline and its
|
||||||
|
* regions arrive as separate features; the code is what ties them together.
|
||||||
|
*/
|
||||||
|
function collect(features) {
|
||||||
|
const shapes = []
|
||||||
|
const byCode = new Map()
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
const code = feature.properties?.CODE
|
||||||
|
if (!code) continue
|
||||||
|
const polygons = ringsOf(feature.geometry)
|
||||||
|
if (polygons.length === 0) continue
|
||||||
|
|
||||||
|
let shape = byCode.get(code)
|
||||||
|
if (!shape) {
|
||||||
|
shape = { code, isState: feature.properties.LEVEL === 1, rings: [], seen: new Set() }
|
||||||
|
byCode.set(code, shape)
|
||||||
|
shapes.push(shape)
|
||||||
|
}
|
||||||
|
// A polygon with holes only reads correctly under an even-odd fill, which
|
||||||
|
// is the rule mapshaper's SVG output used to pick for the same reason.
|
||||||
|
const evenOdd = polygons.some((polygon) => polygon.length > 1)
|
||||||
|
|
||||||
|
for (const polygon of polygons) {
|
||||||
|
for (const ring of polygon) {
|
||||||
|
if (ring.length >= 3) shape.rings.push({ ring, evenOdd })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shapes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Twice the signed area of a closed ring of `x, y` pairs. */
|
||||||
|
function shoelace(points) {
|
||||||
|
let sum = 0
|
||||||
|
const count = points.length / 2
|
||||||
|
for (let i = 0, j = count - 1; i < count; j = i++) {
|
||||||
|
sum += points[j * 2] * points[i * 2 + 1] - points[i * 2] * points[j * 2 + 1]
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snaps every ring to the output grid and throws away what that makes
|
||||||
|
* redundant: points that land on the one before them, and rings left too small
|
||||||
|
* to enclose anything. The simplification upstream works to about this
|
||||||
|
* precision, so a good share of the raw points say nothing once rounded.
|
||||||
|
*/
|
||||||
|
function quantize(shapes, canvas) {
|
||||||
|
const kept = []
|
||||||
|
for (const shape of shapes) {
|
||||||
|
const seen = new Set()
|
||||||
|
const rings = []
|
||||||
|
for (const { ring, evenOdd } of shape.rings) {
|
||||||
|
const points = []
|
||||||
|
let px = null
|
||||||
|
let py = null
|
||||||
|
for (const [rx, ry] of ring) {
|
||||||
|
const x = canvas.x(rx)
|
||||||
|
const y = canvas.y(ry)
|
||||||
|
if (x === px && y === py) continue
|
||||||
|
points.push(x, y)
|
||||||
|
px = x
|
||||||
|
py = y
|
||||||
|
}
|
||||||
|
if (points.length < 6) continue
|
||||||
|
// Coordinates are integers now, so any ring that still encloses area
|
||||||
|
// encloses at least half a grid square. One that comes out at exactly
|
||||||
|
// zero is a sliver the rounding flattened, and it would draw nothing.
|
||||||
|
if (shoelace(points) === 0) continue
|
||||||
|
// Antarctica arrives twice over, once per admin level, because its source
|
||||||
|
// carries no country code and both levels land on ATA. A ring counted
|
||||||
|
// twice cancels itself out in the even-odd test the app hit tests with,
|
||||||
|
// which would leave the shape impossible to tap.
|
||||||
|
const key = points.join(',')
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
rings.push({ points, evenOdd })
|
||||||
|
}
|
||||||
|
if (rings.length > 0) kept.push({ code: shape.code, isState: shape.isState, rings })
|
||||||
|
}
|
||||||
|
// By code, which puts a country next to its own regions. They are neighbours
|
||||||
|
// on the map, so their coordinates are similar, and the compressor in the APK
|
||||||
|
// gets noticeably more out of the file for it.
|
||||||
|
return kept.sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Projected metres in, user units on a MAP_WIDTH canvas out, y flipped. */
|
||||||
|
function fitToCanvas(shapes) {
|
||||||
|
let minX = Infinity
|
||||||
|
let minY = Infinity
|
||||||
|
let maxX = -Infinity
|
||||||
|
let maxY = -Infinity
|
||||||
|
for (const shape of shapes) {
|
||||||
|
for (const { ring } of shape.rings) {
|
||||||
|
for (const [x, y] of ring) {
|
||||||
|
if (x < minX) minX = x
|
||||||
|
if (x > maxX) maxX = x
|
||||||
|
if (y < minY) minY = y
|
||||||
|
if (y > maxY) maxY = y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scale = (MAP_WIDTH - 2 * MARGIN) / (maxX - minX)
|
||||||
|
return {
|
||||||
|
width: MAP_WIDTH,
|
||||||
|
height: 2 * MARGIN + (maxY - minY) * scale,
|
||||||
|
x: (v) => Math.round((MARGIN + (v - minX) * scale) * PRECISION),
|
||||||
|
y: (v) => Math.round((MARGIN + (maxY - v) * scale) * PRECISION),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pack(path) {
|
||||||
|
const geojson = JSON.parse(readFileSync(path, 'utf8'))
|
||||||
|
const collected = collect(geojson.features ?? [])
|
||||||
|
const canvas = fitToCanvas(collected)
|
||||||
|
const shapes = quantize(collected, canvas)
|
||||||
|
|
||||||
|
const bytes = [0x42, 0x4d, 0x41, 0x50, 0x02] // "BMAP" v2
|
||||||
|
uvarint(bytes, PRECISION)
|
||||||
|
uvarint(bytes, Math.round(canvas.width * PRECISION))
|
||||||
|
uvarint(bytes, Math.round(canvas.height * PRECISION))
|
||||||
|
uvarint(bytes, shapes.length)
|
||||||
|
|
||||||
|
let rings = 0
|
||||||
|
let points = 0
|
||||||
|
for (const shape of shapes) {
|
||||||
|
const code = Buffer.from(shape.code, 'utf8')
|
||||||
|
uvarint(bytes, code.length)
|
||||||
|
for (const byte of code) bytes.push(byte)
|
||||||
|
bytes.push(shape.isState ? 1 : 0)
|
||||||
|
uvarint(bytes, shape.rings.length)
|
||||||
|
|
||||||
|
for (const { points: ring, evenOdd } of shape.rings) {
|
||||||
|
uvarint(bytes, ring.length / 2)
|
||||||
|
bytes.push(evenOdd ? 1 : 0)
|
||||||
|
let px = 0
|
||||||
|
let py = 0
|
||||||
|
for (let i = 0; i < ring.length; i += 2) {
|
||||||
|
svarint(bytes, ring[i] - px)
|
||||||
|
svarint(bytes, ring[i + 1] - py)
|
||||||
|
px = ring[i]
|
||||||
|
py = ring[i + 1]
|
||||||
|
}
|
||||||
|
rings++
|
||||||
|
points += ring.length / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = path.replace(/^.*\//, './app/src/main/assets/').replace(/\.geojson$/, '.bmap')
|
||||||
|
writeFileSync(out, Buffer.from(bytes))
|
||||||
|
console.log(
|
||||||
|
`${out}: ${shapes.length} shapes, ${rings} rings, ${points} points, ` +
|
||||||
|
`${canvas.width}x${canvas.height.toFixed(1)}, ${bytes.length} bytes`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputs = process.argv.slice(2)
|
||||||
|
if (inputs.length === 0) {
|
||||||
|
console.error('usage: node packmap.js <map>.geojson ...')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
inputs.forEach(pack)
|
||||||
Reference in New Issue
Block a user