Compare commits
147
Commits
1.4
...
e03db203a3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e03db203a3 | ||
|
|
d6d132149e | ||
|
|
baf619f9a3 | ||
|
|
bf7b884c43 | ||
|
|
cd4c4546cb | ||
|
|
18e1fbef06 | ||
|
|
93ea46034f | ||
|
|
20caece8ce | ||
|
|
e39d48ccdc | ||
|
|
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 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -23,3 +23,5 @@ 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
|
||||||
+13
-23
@@ -1,8 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.0'
|
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.20'
|
||||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.0'
|
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.20'
|
||||||
id 'com.mikepenz.aboutlibraries.plugin' version '15.0.3'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.4"
|
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,44 +73,35 @@ 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.4"
|
implementation "androidx.compose.material:material:1.12.1"
|
||||||
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:15.0.3'
|
|
||||||
implementation 'com.mikepenz:aboutlibraries-core:15.0.3'
|
|
||||||
|
|
||||||
|
|
||||||
implementation platform('androidx.compose:compose-bom:2026.06.01')
|
implementation platform('androidx.compose:compose-bom:2026.08.00')
|
||||||
debugImplementation 'androidx.compose.ui:ui-tooling:1.11.4'
|
debugImplementation 'androidx.compose.ui:ui-tooling:1.12.1'
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.configureEach { task ->
|
tasks.configureEach { task ->
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" width="800" height="800" viewBox="0 0 800 800" stroke-linecap="round" stroke-linejoin="round" data-processed="true"><g id="NNN"><g id="AIA1"><path d="M 257.9 471.7 257.8 471.6 257.9 471.6 257.9 471.7 Z" id="AIA_DogIsland"/><path d="M 257.9 472 257.8 472.1 257.8 472 257.9 472 Z" id="AIA_WE"/><path d="M 257.9 472.2 257.8 472.1 257.9 472.1 257.9 472.2 Z" id="AIA_BP"/><path d="M 258 472.2 258.1 472.3 258 472.3 258 472.2 Z" id="AIA_TF"/><path d="M 257.9 472.2 257.9 472.1 257.8 472.1 257.9 472 257.9 472.1 257.9 472.2 Z" id="AIA_SO"/><path d="M 257.9 472.2 257.9 472.1 258 472.1 258 472.2 257.9 472.2 Z" id="AIA_GH"/><path d="M 258.1 472.3 258 472.2 258.1 472.2 258.1 472.3 Z" id="AIA_ST"/></g><g id="ATG1"><path d="M 257.5 476.5 257.4 476.5 257.4 476.4 257.5 476.4 257.6 476.4 257.5 476.4 257.5 476.5 Z" id="ATG_GE"/><path d="M 257.4 476.6 257.5 476.6 257.5 476.7 257.5 476.8 257.4 476.8 257.4 476.7 257.4 476.6 Z" id="ATG_PH"/><path d="M 257.4 476.5 257.5 476.5 257.6 476.5 257.6 476.6 257.5 476.6 257.4 476.6 257.4 476.5 Z" id="ATG_PE"/><path d="M 257.3 476.5 257.4 476.5 257.4 476.6 257.3 476.7 257.3 476.6 257.3 476.5 257.2 476.5 257.3 476.5 Z" id="ATG_PA"/><path d="M 257.4 476.3 257.3 476.4 257.3 476.5 257.2 476.5 257.2 476.4 257.2 476.3 257.3 476.3 257.3 476.2 257.2 476.2 257.3 476.2 257.3 476.3 257.4 476.3 Z" id="ATG_MA"/><path d="M 257.6 476.4 257.5 476.4 257.4 476.4 257.4 476.5 257.3 476.5 257.3 476.4 257.4 476.3 257.3 476.2 257.4 476.2 257.4 476.3 257.5 476.3 257.6 476.4 Z" id="ATG_JO"/><path d="M 258.6 475.7 258.6 476 258.4 476.1 258.3 475.8 258.5 475.7 258.5 475.6 258.6 475.7 Z" id="ATG_BB"/></g><g id="VGB1"><path d="M 256.4 467.8 256.3 467.7 256.4 467.7 256.4 467.8 Z" id="VGB_JostVanDyke"/><path d="M 257.5 468.5 257.5 468.6 257.4 468.5 257.5 468.5 Z" id="VGB_Anegada"/><path d="M 256.9 468.6 256.8 468.5 256.7 468.6 256.7 468.5 256.8 468.5 256.8 468.6 256.9 468.5 256.9 468.6 Z" id="VGB_VirginGorda"/><path d="M 256.6 468.2 256.5 468.2 256.3 468 256.5 468 256.6 468.2 Z" id="VGB_Tortola"/></g><g id="BMU1"><path d="M 284.1 454.7 284 454.7 284 454.6 284.1 454.7 Z" id="BMU_DE"/><path d="M 283.9 454.4 284 454.5 283.9 454.5 283.9 454.4 Z" id="BMU_SA"/><path d="M 284.3 454.8 284.3 454.7 284.3 454.8 284.2 454.7 284.3 454.7 284.3 454.8 Z" id="BMU_SC"/></g><g id="VCT1"><path d="M 250.8 482 250.7 482.1 250.6 482 250.5 482 250.6 481.9 250.7 482 250.8 482 Z" id="VCT_AN"/><path d="M 250.8 482.1 250.8 482 250.7 482 250.6 481.9 250.7 481.8 250.8 481.8 250.8 481.9 250.8 482 250.8 482.1 Z" id="VCT_PA"/><path d="M 250.7 482.1 250.7 482.3 250.6 482.3 250.5 482.1 250.6 482 250.7 482.1 Z" id="VCT_GE"/><path d="M 251.1 482 250.9 482.1 250.8 482.1 250.8 482 250.8 481.9 250.8 481.8 250.8 481.9 251.1 481.9 251.1 482 Z" id="VCT_DA"/><path d="M 250.7 482.1 250.8 482 250.8 482.1 250.9 482.1 251.1 482 250.9 482.2 250.9 482.3 250.8 482.3 250.7 482.3 250.7 482.1 Z" id="VCT_CH"/></g><g id="PRI1"><path d="M 255.6 466.6 255.5 466.6 255.6 466.5 255.6 466.6 Z" id="PRI_CU"/><path d="M 254.7 464.3 254.7 464.2 254.8 464.2 254.8 464.3 254.8 464.2 254.8 464.3 254.7 464.3 Z" id="PRI_CT"/><path d="M 254 465 253.9 464.9 253.9 464.8 254 464.8 254 465 Z" id="PRI_AR"/><path d="M 253 462.1 253.1 461.9 253.1 462 253.1 462.1 253 462.1 Z" id="PRI_HO"/><path d="M 254 463.2 254.2 463.1 254.2 463.3 254.1 463.3 254 463.2 Z" id="PRI_FL"/><path d="M 253.4 461.5 253.4 461.7 253.3 461.7 253.3 461.6 253.3 461.5 253.3 461.4 253.4 461.5 Z" id="PRI_RC"/><path d="M 254.2 465.4 254.2 465.3 254.1 465.3 254.1 465.2 254.2 465 254.2 465.3 254.2 465.4 Z" id="PRI_MB"/><path d="M 254.6 464.7 254.6 464.6 254.8 464.7 254.7 464.8 254.7 464.9 254.6 464.7 Z" id="PRI_TJ"/><path d="M 253.9 463.7 253.7 463.8 253.7 463.5 253.9 463.7 Z" id="PRI_VL"/><path d="M 254.2 463.1 254.3 463.1 254.4 463.1 254.4 463.2 254.3 463.3 254.3 463.2 254.2 463.3 254.2 463.1 Z" id="PRI_BC"/><path d="M 254 462.3 253.9 462.3 253.8 462.4 253.8 462.3 253.8 462.2 253.9 462.1 254 462.1 254 462.2 254 462.3 Z" id="PRI_QB"/><path d="M 254.6 463.8 254.7 464 254.6 464 254.5 464 254.5 463.9 254.6 463.8 Z" id="PRI_DO"/><path d="M 254.3 464.3 254.3 464.4 254.3 464.3 254.1 464.2 254.2 464.1 254.3 464.2 254.4 464.2 254.3 464.3 Z" id="PRI_CM"/><path d="M 254.9 464.8 255 464.7 254.9 464.7 255 464.6 255 464.8 255.1 464.9 255 464.9 255.1 465 254.9 465 254.9 464.8 Z" id="PRI_LZ"/><path d="M 254.7 465 254.7 465.1 254.4 465 254.5 465 254.5 464.9 254.7 465 Z" id="PRI_JC"/><path d="M 254.4 463.9 254.4 463.8 254.3 463.8 254.3 463.7 254.5 463.7 254.6 463.7 254.6 463.8 254.5 463.9 254.4 463.9 Z" id="PRI_VA"/><path d="M 254.7 464.2 254.6 464.1 254.5 464 254.6 464 254.7 464 254.7 464.1 254.8 464.1 254.7 464.1 254.8 464.2 254.7 464.2 Z" id="PRI_TB"/><path d="M 254.2 464.1 254.2 464 254.3 464 254.4 464 254.4 464.2 254.3 464.2 254.2 464.1 Z" id="PRI_NR"/><path d="M 254 464.1 254.1 464.2 254.1 464.3 254 464.3 253.9 464.3 254 464.1 Z" id="PRI_AI"/><path d="M 254.9 465.4 254.9 465Line truncated
|
|
||||||
|
Before Width: | Height: | Size: 3.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" width="800" height="390" viewBox="0 0 800 390" stroke-linecap="round" stroke-linejoin="round" data-processed="true"><g id="UUU"><g id="MHL1"><path d="M 768.9 168.2 768.8 168.2 768.9 168.1 768.9 168.2 Z" id="MHL_Lae"/><path d="M 777 174.2 777 174.3 776.9 174.2 777 174.2 Z" id="MHL_Jaluit"/><path d="M 774.6 165.7 774.5 165.7 774.5 165.6 774.6 165.7 Z" id="MHL_Likiep"/><path d="M 777.5 166.9 777.6 167 777.5 167 777.5 166.9 Z" id="MHL_Wotje"/><path d="M 780.7 172.2 780.6 172.2 780.6 172.1 780.7 172.2 Z" id="MHL_MAJ"/><path d="M 782.1 172.2 782 172.3 782 172.2 782.1 172.2 782 172.2 782.1 172.2 Z" id="MHL_Arno"/></g><g id="TUV1"><path d="M 797 204.8 797.1 204.9 797 204.9 797 204.8 Z" id="TUV_VI"/></g><g id="PLW1"><path d="M 698.7 171.3 698.7 171.2 698.8 171.2 698.7 171.3 Z" id="PLW_NW"/><path d="M 698.4 171.7 698.4 171.8 698.4 171.9 698.3 171.9 698.2 172 698.3 171.9 698.4 171.8 698.4 171.7 Z" id="PLW_KO"/><path d="M 698.7 171.4 698.7 171.3 698.6 171.3 698.7 171.3 698.8 171.4 698.7 171.4 Z" id="PLW_ME"/><path d="M 698.7 171.1 698.7 171.2 698.6 171.2 698.5 171.2 698.7 171.1 Z" id="PLW_NM"/><path d="M 698.5 171.4 698.5 171.3 698.6 171.4 698.6 171.5 698.5 171.4 Z" id="PLW_NP"/><path d="M 698.7 171.2 698.7 171.1 698.7 170.9 698.8 171.2 698.7 171.2 Z" id="PLW_ND"/><path d="M 698.5 171.4 698.6 171.5 698.6 171.6 698.5 171.6 698.5 171.5 698.4 171.4 698.5 171.4 Z" id="PLW_AM"/><path d="M 698.7 171.5 698.6 171.5 698.6 171.4 698.7 171.3 698.7 171.4 698.8 171.4 698.7 171.4 698.7 171.5 Z" id="PLW_NS"/><path d="M 698.5 171.6 698.6 171.6 698.6 171.5 698.7 171.5 698.7 171.6 698.7 171.7 698.7 171.6 698.6 171.6 698.6 171.7 698.5 171.6 Z" id="PLW_AR"/><path d="M 698.6 171.3 698.7 171.3 698.6 171.4 698.5 171.3 698.5 171.2 698.6 171.2 698.7 171.2 698.6 171.3 Z" id="PLW_NL"/></g><g id="COK1"><path d="M 46.4 208.2 46.5 208.2 46.5 208.3 46.4 208.2 Z" id="COK_Penhryn"/><path d="M 53 232.6 52.8 232.5 52.9 232.5 53 232.6 Z" id="COK_Mitiaro"/><path d="M 52.1 232.8 52.2 232.9 52 232.8 52.1 232.8 Z" id="COK_Atiu"/><path d="M 47.6 230.2 47.6 230.3 47.7 230.4 47.6 230.4 47.6 230.3 47.6 230.2 Z" id="COK_Aitutaki"/><path d="M 54 237.1 54.1 237.2 54 237.2 53.9 237.2 53.9 237.1 54 237.1 Z" id="COK_Mangaia"/><path d="M 49.4 235.6 49.5 235.6 49.5 235.7 49.3 235.7 49.3 235.5 49.4 235.6 Z" id="COK_Rarotonga"/><path d="M 39.6 228.4 39.7 228.5 39.7 228.6 39.6 228.5 39.6 228.4 Z" id="COK_Palmerston"/></g><g id="TKL1"></g><g id="NRU1"><path d="M 771.8 189.3 771.9 189.3 771.9 189.4 771.8 189.3 Z" id="NRU_YA"/></g><g id="FJI1"><path d="M 4.1 227.1 4.1 227.2 4 227.2 4 227.1 4.1 227.1 Z M 790.1 230.5 790.3 230.5 790.3 230.7 790.1 230.7 789.6 230.6 790 230.5 790.1 230.5 Z M 793.9 229.6 793.9 229.7 793.8 229.6 793.9 229.6 Z M 792.9 228.2 793 228.6 792.7 228.4 792.9 228.2 Z M 4.1 226.5 4.3 226.6 4.3 226.7 4.1 226.5 Z" fill-rule="evenodd" id="FJI_EA"/><path d="M 790.2 229 790.2 228.9 790.2 229 790.1 229 790.1 228.9 790 228.9 789.8 229 789.6 229 789.5 229 789.4 228.9 789.5 228.8 789.5 228.5 789.6 228.5 789.6 228.4 789.7 228.4 789.8 228.4 789.9 228.4 790 228.4 790.1 228.2 790.1 228 790.4 227.7 790.3 227.8 790.2 227.8 790.2 227.9 790.2 227.8 790.3 227.7 790.3 227.6 790.6 227.7 790.9 227.6 791.2 227.7 791.3 227.4 791.4 227.4 791.4 227.5 791.6 227.6 791.5 227.8 791.5 228.1 791.4 228.2 791.4 228.3 791.4 228.4 791.4 228.5 791.5 228.4 791.5 228.5 791.4 228.6 791.1 228.7 791.1 228.6 791.1 228.7 791 228.7 791.1 228.7 791.1 228.6 791 228.7 790.9 228.7 790.9 228.6 790.7 228.7 790.6 228.7 790.2 228.9 790.2 229 Z" id="FJI_CE"/><path d="M 793.2 225.7 793 226 792.8 225.8 792.5 226.1 792.3 226.1 792.2 225.7 792.2 225.3 792.5 225.4 793.1 225.1 793.2 225 794 224.9 794.5 224.6 795.1 224.3 795.3 224.5 794.9 224.9 794.5 225.1 794.4 225.3 794.2 225.5 794.2 225.6 794.5 225.5 795.1 225.1 795.2 225.1 795.1 225.3 795.1 225.6 794.9 225.5 794.1 225.7 793.8 225.5 793.6 225.5 793.2 225.7 Z M 1.9 225.5 2 225.6 2 225.9 1.8 226 1.7 225.7 1.9 225.5 Z M 793.7 224.7 793.7 224.8 793.6 224.8 793.7 224.7 Z M 794.9 224.4 794.8 224.4 794.8 224.3 794.9 224.3 794.9 224.4 Z" fill-rule="evenodd" id="FJI_NO"/><path d="M 788.8 229.6 788.8 229.5 788.9 229.5 788.8 229.6 Z M 790.3 227.7 790.2 227.8 790.2 227.9 790.2 227.8 790.3 227.8 790.4 227.7 790.1 228 790.1 228.2 790 228.4 789.9 228.4 789.8 228.4 789.7 228.4 789.6 228.4 789.6 228.5 789.5 228.5 789.5 228.8 789.4 228.9 788.9 228.8 788.9 228.7 788.8 228.8 788.5 228.7 788.4 228.4 788.5 228.1 788.9 227.9 788.9 227.6 789.3 227.3 790.1 227 790.3 227.1 790.5 227 790.9 226.9 791 226.9 790.9 227.2 791 227.2 791.2 227.2 791.2 227.4 791.3 227.4 791.2 227.7 790.9 227.6 790.6 227.7 790.3 227.6 790.3 227.7 Z M 790.9 226.8 790.8 226.9 790.8 226.8 790.9 226.8 Z M 789.4 226 789.4 226.1 789.3 226.1 789.3 226 789.4 226 Z M 788.7 226.8 788.6 226.8 788.6 226.7 788.6 226.8 788.7 226.7 788.7 226.8 Z" fill-rule="evenodd" id="FJI_W"/></g><g id="GUM1"><path d="M 719.3 158.4 719.3 158.5 719.2 158.4 719.1 158.4 719.2Line truncated
|
|
||||||
|
Before Width: | Height: | Size: 3.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" width="800" height="972" viewBox="0 0 800 972" stroke-linecap="round" stroke-linejoin="round" data-processed="true"><g id="NNN"><g id="AIA1"><path d="M 260.2 327.5 260.1 327.5 260.2 327.4 260.2 327.5 Z" id="AIA_BP"/><path d="M 260.2 327.4 260.1 327.5 260.1 327.4 260.2 327.4 Z" id="AIA_SO"/><path d="M 260.4 327.3 260.4 327.4 260.3 327.4 260.3 327.3 260.4 327.3 Z" id="AIA_SH"/><path d="M 260.2 327.3 260.3 327.3 260.3 327.4 260.2 327.4 260.2 327.3 Z" id="AIA_ST"/></g><g id="ATG1"><path d="M 263 329.9 263 330 263 329.9 263 329.8 263.1 329.9 263 329.9 Z" id="ATG_GE"/><path d="M 263.1 330.1 263.1 330 263.2 330 263.3 330 263.3 330.1 263.1 330.1 Z" id="ATG_PH"/><path d="M 263 330 263 329.9 263.1 329.9 263.2 329.9 263.2 330 263.1 330 263.1 330.1 263 330 Z" id="ATG_PE"/><path d="M 263 330.1 263.1 330.1 263.2 330.2 263.1 330.2 263 330.2 263 330.1 Z" id="ATG_PA"/><path d="M 262.9 330 263 330.1 263 330.2 262.9 330.2 262.8 330.2 262.8 330.1 262.8 330 262.9 330 Z" id="ATG_MA"/><path d="M 263 329.8 263 329.9 263 330 263 330.1 262.9 330 262.8 330 262.8 329.9 262.9 329.9 262.9 329.8 263 329.8 Z" id="ATG_JO"/></g><g id="VGB1"><path d="M 256.8 327 256.9 327 256.9 327.1 256.8 327 Z" id="VGB_OtherIslands"/><path d="M 257.3 326.7 257.3 326.8 257.2 326.8 257.3 326.7 Z" id="VGB_VirginGorda"/><path d="M 256.9 326.8 256.7 327 256.7 326.8 256.9 326.8 Z" id="VGB_Tortola"/></g><g id="BHS1"><path d="M 229.9 309.9 229.8 309.9 229.8 309.8 229.9 309.9 Z" id="BHS_HB"/><path d="M 228.1 308.1 228.1 308.2 228 308.2 228 308.1 228.1 308.1 Z M 228.1 307.9 228.1 308.1 228.1 308 228 307.9 228.1 307.9 Z" fill-rule="evenodd" id="BHS_MI"/><path d="M 232.2 311.9 232.1 312 232.1 311.9 232.2 311.9 Z" id="BHS_CI"/><path d="M 223.7 314.8 223.8 314.9 223.7 314.9 223.7 314.8 Z" id="BHS_BI"/><path d="M 232.1 317.9 232.2 318 232.1 318 232.1 317.9 Z" id="BHS_RI"/><path d="M 227.4 309.2 227.4 309.4 227.5 309.4 227.4 309.3 227.3 309.2 227.4 309.2 Z" id="BHS_BR"/><path d="M 225.8 307.4 225.6 307.5 225.6 307.4 225.7 307.4 225.8 307.4 Z" id="BHS_FP"/><path d="M 234.1 314.3 234.2 314.5 234 314.4 234.1 314.3 Z" id="BHS_RC"/><path d="M 231.2 311.2 231.2 311 231.2 310.9 231 310.7 231.2 310.8 231.3 310.9 231.2 311 231.3 311.1 231.2 311.2 Z" id="BHS_CE"/><path d="M 237.9 317.4 238.2 317.5 238.1 317.6 237.9 317.6 237.9 317.3 237.9 317.4 Z" id="BHS_MG"/><path d="M 228.5 311 228.5 311.2 228.3 311.2 228.2 311 228.5 311 Z" id="BHS_NW"/><path d="M 231.5 314.4 232 314.8 231.5 314.6 231.5 314.4 Z" id="BHS_EM"/><path d="M 228.6 307.1 228.4 307 228.3 306.8 228.1 306.5 228.1 306.6 227.9 306.6 227.9 306.5 228 306.5 228.1 306.5 228.4 306.8 228.5 306.9 228.6 306.9 228.6 307 228.6 307.1 Z" id="BHS_NB"/><path d="M 227.5 307.2 227.4 307.2 227.5 307.2 227.4 307.2 227.4 307.1 227.5 307.2 Z M 226.7 307 226.2 307.2 226.1 307.1 226.1 307 226.5 307 226.7 307 Z" fill-rule="evenodd" id="BHS_EB"/><path d="M 231.2 311.2 231.2 311.3 231.2 311.6 231.1 311.9 230.8 311.6 231.1 311.6 231.2 311.6 231.2 311.5 231.1 311.5 231.1 311.6 231.1 311.5 231.1 311.4 231.1 311.3 231.2 311.2 Z" id="BHS_SE"/><path d="M 235.3 316.4 235.3 316.5 235.4 316.5 235.5 316.5 235.8 316.7 235.8 316.9 235.4 316.8 235.2 316.4 235.3 316.4 Z" id="BHS_CK"/><path d="M 235.9 317 236.2 316.7 236.2 316.8 236.2 316.9 236.3 316.9 236.3 317 236.3 317.1 236 317.7 235.8 317.5 236.2 317.3 236.1 317 235.9 317 Z" id="BHS_AK"/><path d="M 231 310.7 230.9 310.6 230.8 310.6 230 310.1 229.8 310.3 229.7 310.4 229.6 310.4 229.7 310.4 229.9 309.8 230 309.8 230.1 309.8 230.4 310.2 230.8 310.5 230.9 310.5 230.9 310.6 231 310.7 Z" id="BHS_NE"/><path d="M 228.9 308.1 229.1 308 229 308.1 228.9 308.2 228.9 309.1 228.8 309.1 228.7 308.8 228.8 308.2 228.8 308.1 228.9 308.1 Z" id="BHS_SB"/><path d="M 233.4 315.6 233.5 315.6 233.5 315.7 233.4 315.6 Z M 233.1 314.4 233.3 314.8 233.3 315 233.5 315.3 233.6 315.7 233.9 315.8 234.1 316 234.1 316.3 234.1 316.4 233.8 315.9 233.6 315.9 233.6 315.8 233.4 315.6 233.5 315.4 233.4 315.1 233.2 315 233 314.4 233.1 314.4 Z" fill-rule="evenodd" id="BHS_LI"/><path d="M 227.8 312.9 227.9 313.1 227.8 313.1 227.7 313.3 227.8 313.4 227.5 313.6 227.3 313.4 227.6 313 227.7 313 227.7 312.9 227.8 312.9 Z M 227.8 313.2 227.8 313.3 227.7 313.3 227.7 313.2 227.8 313.2 Z M 227.9 313.1 227.9 313.2 227.8 313.2 227.8 313.1 227.9 313.1 Z" fill-rule="evenodd" id="BHS_MC"/><path d="M 225.7 307.4 225.6 307.4 225.6 307.5 225.5 307.5 225.4 307.4 225.4 307.5 224.9 307 225 307 225 307.1 225.1 307.1 225.1 307 225.1 307.1 225.4 307.4 225.7 307.3 225.8 307 225.7 307 225.8 306.9 225.7 306.9 225.8 306.9 225.8 307 225.8 306.9 225.9 306.8 225.9 306.9 225.9 306.8 225.9 306.9 225.9 307 225.9 306.9 226 306.9 226 307 226.1 307 226.1 307.1 226.2 307.2 226.1 307.3 225.9 307.4 225.8 307.4 225.7 307.4 Z M 226.1 306.8 226.1 306.9 226 306.9 226 306.8 226.1 306.8 Z" fill-rule="evenodd" id="BHS_WB"/><path d="M 228.7 307.7 228.8 307.7 228.7 307.8 228.7 307.7 Z M 229.2 307.4 229.2 307.6 229.2 307.8 22Line truncated
|
|
||||||
|
Before Width: | Height: | Size: 4.0 MiB |
@@ -1,20 +1,20 @@
|
|||||||
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.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
|
||||||
import androidx.compose.material.Scaffold
|
import androidx.compose.material.Scaffold
|
||||||
import androidx.compose.material.Surface
|
|
||||||
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
|
||||||
@@ -22,29 +22,42 @@ 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.Color
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
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)
|
||||||
@@ -53,21 +66,30 @@ class MainScreen : ComponentActivity() {
|
|||||||
Data.loadData(this, Int.MIN_VALUE)
|
Data.loadData(this, Int.MIN_VALUE)
|
||||||
GeoLocImporter.importStates(this)
|
GeoLocImporter.importStates(this)
|
||||||
|
|
||||||
|
refreshProjection()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SysTheme {
|
SysTheme {
|
||||||
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.primary).statusBarsPadding(),) {
|
// 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") } }
|
||||||
@@ -75,7 +97,7 @@ class MainScreen : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreenC(psvg: SVGWrapper,css: CSSWrapper, nav: NavHostController){
|
fun MainScreenC(world: MapWorld?, nav: NavHostController){
|
||||||
SysTheme {
|
SysTheme {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -95,34 +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)
|
|
||||||
maximumScale = 32f
|
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Both, because the app mixes the two: most of it is Material 2, while the
|
||||||
|
// dividers are Material 3 and would otherwise fall back to the default
|
||||||
|
// light scheme.
|
||||||
|
androidx.compose.material3.MaterialTheme(colorScheme = colorScheme) {
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colors = m2colors,
|
colors = m2colors,
|
||||||
content = content
|
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,169 +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)
|
|
||||||
Data.saveData()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
var showGroupDialog by remember { mutableStateOf(false) }
|
||||||
modifier = Modifier
|
var showRegionalDialog by remember { mutableStateOf(false) }
|
||||||
.fillMaxSize()
|
var showLoad by remember { mutableStateOf(false) }
|
||||||
.padding(16.dp)
|
val scope = rememberCoroutineScope()
|
||||||
.background(MaterialTheme.colors.background)
|
|
||||||
) {
|
if (showGroupDialog) {
|
||||||
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(
|
EditPlaceColorDialog(
|
||||||
deleteMode = true,
|
deleteMode = true,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
val g = Data.selected_group
|
val g = Data.selected_group
|
||||||
if (g != null) {
|
if (g != null) {
|
||||||
Data.visits.reassignAllVisitedToGroup(g.key)
|
Data.visits.reassignAllVisitedToGroup(g.key)
|
||||||
|
Data.groups.keepOnly(g.key)
|
||||||
Data.saveData()
|
Data.saveData()
|
||||||
|
groups = offString
|
||||||
|
prefs.edit { putString(keyGroup, offString) }
|
||||||
}
|
}
|
||||||
showDialog = false
|
showGroupDialog = 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
|
if (showRegionalDialog) {
|
||||||
fun RegionalScreen() {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val keyRegional = stringResource(R.string.key_regional)
|
|
||||||
val offString = stringResource(R.string.off)
|
|
||||||
val onString = stringResource(R.string.on)
|
|
||||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
var selected by remember { mutableStateOf(prefs.getString(keyRegional, offString)!!)}
|
|
||||||
var regional by remember{ mutableStateOf(prefs.getString(keyRegional, offString)!!)}
|
|
||||||
var showDialog by remember{mutableStateOf(false)}
|
|
||||||
var showLoad by remember{mutableStateOf(false)}
|
|
||||||
|
|
||||||
if(showDialog)
|
|
||||||
Dialog(
|
Dialog(
|
||||||
content = {
|
content = {
|
||||||
Column(
|
Column(
|
||||||
@@ -312,25 +204,26 @@ 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
showDialog=false
|
|
||||||
}) {
|
}) {
|
||||||
Text(stringResource(R.string.ok))
|
Text(stringResource(R.string.ok))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDismissRequest = { showDialog=false }
|
onDismissRequest = { showRegionalDialog = false }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showLoad) {
|
if (showLoad) {
|
||||||
Dialog(
|
Dialog(
|
||||||
content = {
|
content = {
|
||||||
@@ -343,15 +236,78 @@ 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) }
|
||||||
}
|
}
|
||||||
onString -> {
|
HorizontalDivider()
|
||||||
regional = key
|
}
|
||||||
prefs.edit { putString(keyRegional, key) }
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
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
|
showLoad = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -359,25 +315,106 @@ fun RegionalScreen() {
|
|||||||
}
|
}
|
||||||
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -393,3 +430,38 @@ fun PreferenceButton(text: String, onClick: () -> Unit) {
|
|||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
val area = when (activeMode) {
|
fun getExplicitColor(l: GeoLoc): Int? {
|
||||||
LocType.WORLD -> World.WWW.children.filter { it.code in visited }.sumOf { it.area }
|
val c = visits.getOrDefault(l.code, 0)
|
||||||
LocType.COUNTRY -> World.WWW.children.flatMap { it.children.filter { c -> c.code in visited } }.sumOf { it.area }
|
return if (c != NO_GROUP && c != AUTO_GROUP) c else null
|
||||||
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²")
|
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 count = visitedItems.size
|
||||||
|
val area = visitedItems.sumOf { it.area.toLong() }
|
||||||
|
|
||||||
|
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,6 +55,44 @@ fun EditPlaceScreenPreview(){
|
|||||||
EditPlaceScreen(Group.EEE)
|
EditPlaceScreen(Group.EEE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
fun syncVisited(loc: GeoLoc?=World.WWW): Boolean {
|
||||||
var changed = false
|
var changed = false
|
||||||
loc?.children?.forEach { tt ->
|
loc?.children?.forEach { tt ->
|
||||||
@@ -122,22 +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)
|
|
||||||
syncVisited()
|
|
||||||
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)
|
|
||||||
syncVisited()
|
|
||||||
Data.saveData()
|
|
||||||
}
|
|
||||||
Data.selected_geoloc = null
|
|
||||||
Data.selected_group = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
@@ -167,22 +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.saveData()
|
|
||||||
Data.selected_group = null
|
|
||||||
} else {
|
|
||||||
Data.selected_group = null
|
|
||||||
showEdit=true
|
|
||||||
}
|
|
||||||
syncVisited()
|
|
||||||
Data.saveData()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -196,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) {
|
||||||
@@ -235,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,6 +43,13 @@ class Groups(val id: Int, @SerialName("grps") private val groups: HashMap<Int, G
|
|||||||
updateFlow()
|
updateFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun keepOnly(key: Int) {
|
||||||
|
val keep = groups[key] ?: return
|
||||||
|
groups.clear()
|
||||||
|
groups[key] = keep
|
||||||
|
updateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
private fun 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),
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.Beans" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
<style name="Theme.Beans" parent="android:Theme.Material.NoActionBar">
|
||||||
<!-- Status bar color in dark mode -->
|
<!-- Status bar color in dark mode -->
|
||||||
<item name="android:statusBarColor">@color/blue</item>
|
<item name="android:statusBarColor">@color/blue</item>
|
||||||
<!-- Window background in dark mode -->
|
<!-- Window background in dark mode -->
|
||||||
@@ -8,8 +8,5 @@
|
|||||||
<item name="android:windowLightStatusBar">false</item>
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||||
|
|
||||||
<item name="colorPrimary">@color/blue</item>
|
|
||||||
<item name="colorPrimaryDark">@color/blue</item>
|
|
||||||
<item name="colorAccent">@color/blue</item>
|
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.Beans" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
<style name="Theme.Beans" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
<!-- Status bar color -->
|
<!-- Status bar color -->
|
||||||
<item name="android:statusBarColor">@color/blue</item>
|
<item name="android:statusBarColor">@color/blue</item>
|
||||||
<!-- Window background - shows through statusBarsPadding() area -->
|
<!-- Window background - shows through statusBarsPadding() area -->
|
||||||
@@ -8,9 +8,5 @@
|
|||||||
<item name="android:windowLightStatusBar">false</item>
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||||
|
|
||||||
<!-- Optional: Primary colors for the theme -->
|
|
||||||
<item name="colorPrimary">@color/blue</item>
|
|
||||||
<item name="colorPrimaryDark">@color/blue</item>
|
|
||||||
<item name="colorAccent">@color/blue</item>
|
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</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.3.2' apply false
|
||||||
id 'org.jetbrains.kotlin.android' version '2.4.0' apply false
|
id 'org.jetbrains.kotlin.android' version '2.4.20' apply false
|
||||||
}
|
}
|
||||||
+1
-51
@@ -1,10 +1,8 @@
|
|||||||
#!/bin/node
|
#!/bin/node
|
||||||
|
|
||||||
import {readFileSync,writeFileSync,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'
|
||||||
import * as path from 'path';
|
|
||||||
import { JSDOM } from 'jsdom';
|
|
||||||
|
|
||||||
|
|
||||||
const countries =
|
const countries =
|
||||||
@@ -162,52 +160,4 @@ async function run(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function fixSvg(svgPath) {
|
|
||||||
const countryToRegion = {};
|
|
||||||
for (const [region, countries] of Object.entries(groups)) {
|
|
||||||
countries.forEach(country => countryToRegion[country] = region);
|
|
||||||
}
|
|
||||||
const absoluteInputPath = path.resolve(svgPath);
|
|
||||||
if (!existsSync(absoluteInputPath)) {
|
|
||||||
throw new Error(`Input file not found at: ${absoluteInputPath}`);
|
|
||||||
}
|
|
||||||
const svgContent = readFileSync(absoluteInputPath, 'utf8');
|
|
||||||
const dom = new JSDOM(svgContent, { contentType: 'image/svg+xml' });
|
|
||||||
const document = dom.window.document;
|
|
||||||
const svgRoot = document.querySelector('svg');
|
|
||||||
if (!svgRoot) {
|
|
||||||
throw new Error("Invalid or empty SVG structure encountered.");
|
|
||||||
}
|
|
||||||
if (svgRoot.getAttribute('data-processed') === 'true') {
|
|
||||||
console.log(`Skipping: File at "${svgPath}" has already been processed.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elementGroups = Array.from(svgRoot.querySelectorAll('g'));
|
|
||||||
elementGroups.forEach(group => {
|
|
||||||
const currentId = group.getAttribute('id') || '';
|
|
||||||
const baseIsoCode = currentId.replace(/\d+$/, '');
|
|
||||||
const regionKey = countryToRegion[baseIsoCode] || 'XXXX';
|
|
||||||
let regionGroup = svgRoot.querySelector(`g[id="${regionKey}"]`);
|
|
||||||
if (!regionGroup) {
|
|
||||||
regionGroup = document.createElementNS('http://w3.org', 'g');
|
|
||||||
regionGroup.setAttribute('id', regionKey);
|
|
||||||
svgRoot.appendChild(regionGroup);
|
|
||||||
}
|
|
||||||
regionGroup.appendChild(group);
|
|
||||||
});
|
|
||||||
svgRoot.setAttribute('data-processed', 'true');
|
|
||||||
const absoluteOutputPath = path.resolve(svgPath);
|
|
||||||
let cleanXmlString = svgRoot.outerHTML;
|
|
||||||
cleanXmlString = cleanXmlString.replace(/[\r\n]+/g, '');
|
|
||||||
cleanXmlString = cleanXmlString.replace(/xmlns="http:\/\/w3\.org"\s?/g, '');
|
|
||||||
cleanXmlString = cleanXmlString.replace(/xmlns="http:\/\/www\.w3\.org\/2000\/svg"\s?/g, '');
|
|
||||||
cleanXmlString = cleanXmlString.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
|
|
||||||
writeFileSync(absoluteOutputPath, cleanXmlString, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
run()
|
run()
|
||||||
fixSvg("./app/src/main/assets/loxim01.svg")
|
|
||||||
fixSvg("./app/src/main/assets/webmercator01.svg")
|
|
||||||
fixSvg("./app/src/main/assets/aeqd01.svg")
|
|
||||||
+18
-57
@@ -1,7 +1,5 @@
|
|||||||
#!/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"
|
||||||
|
|
||||||
@@ -80,43 +78,7 @@ download_1() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
toSVG_0() {
|
generate_map() {
|
||||||
local input_files=("ATA")
|
|
||||||
|
|
||||||
for country in "${countries[@]}"
|
|
||||||
do
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
"$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() {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
generate_svg_map() {
|
|
||||||
local OUT_FILE="$1" # First argument: Output destination path
|
local OUT_FILE="$1" # First argument: Output destination path
|
||||||
local PROJ_ARGS="$2" # Second argument: Projection parameters
|
local PROJ_ARGS="$2" # Second argument: Projection parameters
|
||||||
shift 2 # Remove the first two arguments, leaving only the files
|
shift 2 # Remove the first two arguments, leaving only the files
|
||||||
@@ -125,6 +87,9 @@ generate_svg_map() {
|
|||||||
echo "Generating: $OUT_FILE using projection [$PROJ_ARGS]"
|
echo "Generating: $OUT_FILE using projection [$PROJ_ARGS]"
|
||||||
echo "Processing ${#FILES_TO_RUN[@]} files..."
|
echo "Processing ${#FILES_TO_RUN[@]} files..."
|
||||||
|
|
||||||
|
# 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="
|
local JS_PIPELINE_LOGIC="
|
||||||
const conflicts = {
|
const conflicts = {
|
||||||
'Z01': 'IND', 'Z04': 'IND', 'Z05': 'IND', 'Z07': 'IND', 'Z09': 'IND',
|
'Z01': 'IND', 'Z04': 'IND', 'Z05': 'IND', 'Z07': 'IND', 'Z09': 'IND',
|
||||||
@@ -133,32 +98,27 @@ generate_svg_map() {
|
|||||||
};
|
};
|
||||||
let rawCode = GID_0 || 'UNK';
|
let rawCode = GID_0 || 'UNK';
|
||||||
let cCode = conflicts[rawCode] ? conflicts[rawCode] : rawCode;
|
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 === '');
|
let isGidMissing = (!GID || GID === 'undefined' || GID === 'null' || GID === '');
|
||||||
if (isGidMissing) {
|
CODE = isGidMissing ? cCode : GID;
|
||||||
COUNTRY_GROUP = cCode+'2';
|
LEVEL = isGidMissing ? 0 : 1;
|
||||||
} else {
|
|
||||||
COUNTRY_GROUP = cCode+'1';
|
|
||||||
}
|
|
||||||
"
|
"
|
||||||
|
|
||||||
"$mapshaper" -i "${FILES_TO_RUN[@]}" combine-files \
|
"$mapshaper" -i "${FILES_TO_RUN[@]}" combine-files \
|
||||||
-snap \
|
-snap \
|
||||||
-merge-layers force\
|
-merge-layers force\
|
||||||
-proj $PROJ_ARGS densify \
|
-proj $PROJ_ARGS densify \
|
||||||
-simplify 2% weighted keep-shapes \
|
-simplify 10% weighted keep-shapes \
|
||||||
-filter-islands min-area=0 \
|
-filter-islands min-area=0 \
|
||||||
-sort 'this.area' ascending \
|
-sort 'this.area' ascending \
|
||||||
-each "$JS_PIPELINE_LOGIC" \
|
-each "$JS_PIPELINE_LOGIC" \
|
||||||
-split COUNTRY_GROUP \
|
|
||||||
-o "$OUT_FILE" \
|
-o "$OUT_FILE" \
|
||||||
format=svg \
|
format=geojson
|
||||||
id-field=GID \
|
|
||||||
precision=0.1 \
|
|
||||||
target=*
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toSVG_01() {
|
build_maps() {
|
||||||
input_files=()
|
input_files=()
|
||||||
|
|
||||||
input_files+=("./temp/1/ATA.json")
|
input_files+=("./temp/1/ATA.json")
|
||||||
@@ -178,9 +138,12 @@ toSVG_01() {
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
generate_svg_map "./app/src/main/assets/loxim01.svg" "loxim" "${input_files[@]}"
|
generate_map "./mapdata/loxim01.geojson" "loxim" "${input_files[@]}"
|
||||||
generate_svg_map "./app/src/main/assets/webmercator01.svg" "webmercator" "${input_files[@]}"
|
generate_map "./mapdata/webmercator01.geojson" "webmercator" "${input_files[@]}"
|
||||||
generate_svg_map "./app/src/main/assets/aeqd01.svg" "aeqd +lat_0=90" "${input_files[@]}"
|
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() {
|
||||||
@@ -199,8 +162,6 @@ do_0() {
|
|||||||
}
|
}
|
||||||
# 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
|
#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=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
retries=0
|
retries=0
|
||||||
retryBackOffMs=500
|
retryBackOffMs=500
|
||||||
|
|||||||
-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");
|
|
||||||
+3
-1
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"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.22"
|
||||||
},
|
},
|
||||||
"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