Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22be297f0e |
@@ -23,10 +23,8 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: set up secrets
|
- name: set up secrets
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.RELEASE_KEYSTORE }}" > keystore.asc
|
echo "${{ secrets.RELEASE_KEYSTORE }}" > keystore.asc
|
||||||
@@ -34,6 +32,7 @@ jobs:
|
|||||||
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch keystore.asc > app/keystore.properties
|
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch keystore.asc > app/keystore.properties
|
||||||
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch key.asc > app/key.jks
|
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch key.asc > app/key.jks
|
||||||
|
|
||||||
|
- uses: gradle/wrapper-validation-action@v3
|
||||||
|
|
||||||
- name: create and checkout branch
|
- name: create and checkout branch
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
@@ -42,38 +41,17 @@ jobs:
|
|||||||
run: git checkout -B "$BRANCH"
|
run: git checkout -B "$BRANCH"
|
||||||
|
|
||||||
- name: set up JDK
|
- name: set up JDK
|
||||||
uses: actions/setup-java@v6
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
java-version: 21
|
java-version: 17
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
|
cache: 'gradle'
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/actions/setup-gradle@v6
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: ./gradlew testDebugUnitTest
|
|
||||||
|
|
||||||
- name: Build APK
|
- name: Build APK
|
||||||
run: |
|
run: ./gradlew assembleSignedRelease
|
||||||
VERSION_CODE=$(git rev-list --count HEAD)
|
|
||||||
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@v2
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
|
|||||||
+1
-3
@@ -22,6 +22,4 @@ app/release/
|
|||||||
captures/
|
captures/
|
||||||
local.properties
|
local.properties
|
||||||
keystore.properties
|
keystore.properties
|
||||||
key.jks
|
key.jks
|
||||||
# generated map intermediates; the packed .bmap in assets is what ships
|
|
||||||
mapdata/*.geojson
|
|
||||||
@@ -5,8 +5,6 @@
|
|||||||
|
|
||||||
<p>A virtual scratchmap of the world</p>
|
<p>A virtual scratchmap of the world</p>
|
||||||
|
|
||||||
<a href="https://ko-fi.com/I2I615VP5M"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="ko-fi"></a>
|
|
||||||
<br>
|
|
||||||
<img src="https://forthebadge.com/images/badges/built-for-android.svg" alt="Built for Android">
|
<img src="https://forthebadge.com/images/badges/built-for-android.svg" alt="Built for Android">
|
||||||
<img src="https://forthebadge.com/images/badges/built-with-love.svg" alt="Built with love">
|
<img src="https://forthebadge.com/images/badges/built-with-love.svg" alt="Built with love">
|
||||||
<br>
|
<br>
|
||||||
@@ -73,7 +71,7 @@ Thanks to all contributors, the developers of our dependencies, and our users.
|
|||||||
## 📝 License
|
## 📝 License
|
||||||
|
|
||||||
```
|
```
|
||||||
Copyright (C) 2026 Helcel & MYDOLI
|
Copyright (C) 2024 Helcel & MYDOLI
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU General Public License as published by
|
||||||
|
|||||||
+39
-26
@@ -1,24 +1,27 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.10'
|
id 'org.jetbrains.kotlin.android'
|
||||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.10'
|
id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.10'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.compose' version '2.3.10'
|
||||||
|
id 'com.mikepenz.aboutlibraries.plugin' version '13.2.1'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace 'net.helcel.beans'
|
namespace 'net.helcel.beans'
|
||||||
compileSdk = 37
|
compileSdk 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
buildConfigField("String", "APP_NAME", "\"Beans\"")
|
buildConfigField("String", "APP_NAME", "\"Beans\"")
|
||||||
manifestPlaceholders["APP_NAME"] = "Beans"
|
manifestPlaceholders["APP_NAME"] = "Beans"
|
||||||
applicationId 'net.helcel.beans'
|
applicationId 'net.helcel.beans'
|
||||||
minSdk = 28
|
minSdk 28
|
||||||
targetSdk = 37
|
targetSdk 36
|
||||||
versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.5"
|
versionCode 4
|
||||||
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
|
versionName "1.1a"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
register("release") {
|
create("release") {
|
||||||
try {
|
try {
|
||||||
def keystorePropertiesFile = rootProject.file("app/keystore.properties")
|
def keystorePropertiesFile = rootProject.file("app/keystore.properties")
|
||||||
def keystoreProperties = new Properties()
|
def keystoreProperties = new Properties()
|
||||||
@@ -33,18 +36,20 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
debuggable true
|
debuggable true
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
shrinkResources true
|
shrinkResources false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
signedRelease {
|
signedRelease {
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
shrinkResources true
|
shrinkResources false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
@@ -73,10 +78,8 @@ android {
|
|||||||
kotlinCompilerExtensionVersion = "2.2.20"
|
kotlinCompilerExtensionVersion = "2.2.20"
|
||||||
}
|
}
|
||||||
|
|
||||||
androidResources {
|
kotlin {
|
||||||
// The app ships English strings only; without this the libraries drag
|
jvmToolchain(21)
|
||||||
// in some eighty locales of their own.
|
|
||||||
localeFilters += ["en"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lint {
|
lint {
|
||||||
@@ -84,28 +87,38 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
aboutLibraries {
|
||||||
|
library {
|
||||||
|
exclusionPatterns = [~"androidx.*", ~"com.google.android.*", ~"org.jetbrains.*"]
|
||||||
|
}
|
||||||
|
excludeFields = ["generated"]
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'androidx.compose.material3:material3:1.4.0'
|
implementation 'androidx.compose.material3:material3:1.4.0'
|
||||||
implementation "androidx.compose.material:material:1.12.0"
|
implementation "androidx.compose.material:material:1.10.4"
|
||||||
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.10.0'
|
implementation 'androidx.navigation:navigation-compose:2.9.7'
|
||||||
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.10.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.12.4"
|
||||||
|
|
||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
|
implementation 'com.google.android.material:material:1.13.0'
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0'
|
||||||
|
|
||||||
|
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||||
|
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
|
||||||
|
|
||||||
|
implementation 'com.mikepenz:aboutlibraries:13.2.1'
|
||||||
|
implementation 'com.mikepenz:aboutlibraries-compose-m3:13.2.1'
|
||||||
|
implementation 'com.mikepenz:aboutlibraries-core:13.2.1'
|
||||||
|
|
||||||
|
|
||||||
implementation platform('androidx.compose:compose-bom:2026.08.00')
|
implementation platform('androidx.compose:compose-bom:2026.02.01')
|
||||||
debugImplementation 'androidx.compose.ui:ui-tooling:1.12.0'
|
debugImplementation 'androidx.compose.ui:ui-tooling:1.10.4'
|
||||||
}
|
|
||||||
|
|
||||||
tasks.configureEach { task ->
|
|
||||||
if (task.name.startsWith("merge") && task.name.endsWith("Assets")) {
|
|
||||||
task.outputs.upToDateWhen { false }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
android:hardwareAccelerated="false"
|
android:hardwareAccelerated="false"
|
||||||
android:icon="@mipmap/ic_launcher_round"
|
android:icon="@mipmap/ic_launcher_round"
|
||||||
android:label="${APP_NAME}"
|
android:label="${APP_NAME}"
|
||||||
android:supportsRtl="true">
|
android:supportsRtl="true"
|
||||||
|
tools:replace="android:allowBackup">
|
||||||
<activity
|
<activity
|
||||||
android:name=".activity.MainScreen"
|
android:name=".activity.MainScreen"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.1 MiB |
+3102
-3104
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.1 MiB |
@@ -1,16 +1,14 @@
|
|||||||
package net.helcel.beans.activity
|
package net.helcel.beans.activity
|
||||||
|
|
||||||
|
import android.graphics.drawable.PictureDrawable
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.widget.ImageView
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
|
||||||
import androidx.compose.material.CircularProgressIndicator
|
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
import androidx.compose.material.IconButton
|
import androidx.compose.material.IconButton
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
@@ -18,86 +16,63 @@ import androidx.compose.material.Scaffold
|
|||||||
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
|
||||||
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Percent
|
import androidx.compose.material.icons.filled.Percent
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.caverock.androidsvg.RenderOptions
|
||||||
|
import com.github.chrisbanes.photoview.PhotoView
|
||||||
import net.helcel.beans.BuildConfig
|
import net.helcel.beans.BuildConfig
|
||||||
import net.helcel.beans.activity.sub.EditPlaceDialog
|
|
||||||
import net.helcel.beans.activity.sub.MapPickDialog
|
|
||||||
import net.helcel.beans.activity.sub.applyDirectVisit
|
|
||||||
import net.helcel.beans.activity.sub.commitVisitDialog
|
|
||||||
import net.helcel.beans.countries.GeoLoc
|
|
||||||
import net.helcel.beans.countries.GeoLocImporter
|
import net.helcel.beans.countries.GeoLocImporter
|
||||||
import net.helcel.beans.countries.GeoLocTree
|
|
||||||
import net.helcel.beans.helper.Data
|
import net.helcel.beans.helper.Data
|
||||||
import net.helcel.beans.helper.Settings
|
import net.helcel.beans.helper.Settings
|
||||||
import net.helcel.beans.map.MapAssets
|
import net.helcel.beans.svg.CSSWrapper
|
||||||
import net.helcel.beans.map.MapStyle
|
import net.helcel.beans.svg.SVGWrapper
|
||||||
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 var world by mutableStateOf<MapWorld?>(null)
|
private lateinit var psvg: SVGWrapper
|
||||||
private var loadToken = 0
|
private lateinit var css: CSSWrapper
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
actionBar?.hide()
|
actionBar?.hide()
|
||||||
Settings.start(this)
|
Settings.start(this)
|
||||||
Data.loadData(this, Int.MIN_VALUE)
|
|
||||||
GeoLocImporter.importStates(this)
|
GeoLocImporter.importStates(this)
|
||||||
|
Data.loadData(this, Int.MIN_VALUE)
|
||||||
refreshProjection()
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SysTheme {
|
SysTheme {
|
||||||
// Both bars: without the navigation inset the system buttons sit on top of
|
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background)) {
|
||||||
// whatever is at the bottom of a screen, such as the About button.
|
AppNavHost(psvg, css)
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colors.primary)
|
|
||||||
.statusBarsPadding()
|
|
||||||
.navigationBarsPadding(),
|
|
||||||
) {
|
|
||||||
AppNavHost()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
refreshProjection()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavHost() {
|
fun AppNavHost(psvg: SVGWrapper, css: CSSWrapper) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
NavHost(navController, startDestination = "main") {
|
NavHost(navController, startDestination = "main") {
|
||||||
composable("main") { MainScreenC(world, navController) }
|
composable("main") { MainScreenC(psvg,css, navController) }
|
||||||
composable("settings") { SettingsMainScreen { navController.navigate("main") } }
|
composable("settings") { SettingsMainScreen { navController.navigate("main")} }
|
||||||
composable("edit") { EditScreen { navController.navigate("main") } }
|
composable("edit") { EditScreen { navController.navigate("main") } }
|
||||||
composable("stats") { StatsScreen { navController.navigate("main") } }
|
composable("stats") { StatsScreen { navController.navigate("main") } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreenC(world: MapWorld?, nav: NavHostController){
|
fun MainScreenC(psvg: SVGWrapper,css: CSSWrapper, nav: NavHostController){
|
||||||
SysTheme {
|
SysTheme {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -117,87 +92,33 @@ class MainScreen : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
|
Box(modifier = Modifier.padding(innerPadding)) {
|
||||||
if (world == null) LoadingMap() else MapScreen(world)
|
MapScreen(psvg, css)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoadingMap() {
|
fun MapScreen(psvg: SVGWrapper, css: CSSWrapper) {
|
||||||
Box(
|
Box {
|
||||||
modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background),
|
val opt: RenderOptions = RenderOptions.create()
|
||||||
contentAlignment = Alignment.Center,
|
opt.css(css.get())
|
||||||
) {
|
val drawable = remember(psvg, css) {
|
||||||
CircularProgressIndicator(
|
PictureDrawable(psvg.get()?.renderToPicture(opt))
|
||||||
color = MaterialTheme.colors.primary,
|
|
||||||
strokeWidth = 4.dp,
|
|
||||||
modifier = Modifier.size(50.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 = { ctx ->
|
||||||
|
PhotoView(ctx).apply {
|
||||||
AndroidView(
|
setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null)
|
||||||
factory = { MapView(it) },
|
setImageDrawable(drawable)
|
||||||
update = { view ->
|
scaleType = ImageView.ScaleType.FIT_CENTER
|
||||||
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())
|
||||||
modifier = Modifier.fillMaxSize()
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reloads the map asset, on a worker thread since it is a few megabytes. */
|
|
||||||
fun refreshProjection() {
|
fun refreshProjection() {
|
||||||
val asset = MapAssets.assetFor(this)
|
psvg = SVGWrapper(this)
|
||||||
val token = ++loadToken
|
css = CSSWrapper(this)
|
||||||
world = null
|
|
||||||
Thread {
|
|
||||||
val parsed = assets.open(asset).use { MapReader.read(it) }
|
|
||||||
runOnUiThread { if (token == loadToken) world = parsed }
|
|
||||||
}.start()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ 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
|
||||||
@@ -38,7 +37,6 @@ 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
|
||||||
@@ -50,6 +48,10 @@ 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
|
||||||
@@ -58,23 +60,18 @@ 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.countries.GeoLocImporter
|
import net.helcel.beans.activity.sub.EditPlaceDialog
|
||||||
import net.helcel.beans.helper.DEFAULT_TOUCH_RADIUS
|
import net.helcel.beans.activity.sub.LicenseScreen
|
||||||
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 = defaultPreferences(context)
|
val prefs = PreferenceManager.getDefaultSharedPreferences(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()
|
||||||
@@ -103,22 +100,28 @@ fun SysTheme(
|
|||||||
isLight = !darkTheme,
|
isLight = !darkTheme,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Both, because the app mixes the two: most of it is Material 2, while the
|
MaterialTheme(
|
||||||
// dividers are Material 3 and would otherwise fall back to the default
|
colors = m2colors,
|
||||||
// light scheme.
|
content = content
|
||||||
androidx.compose.material3.MaterialTheme(colorScheme = colorScheme) {
|
)
|
||||||
MaterialTheme(
|
|
||||||
colors = m2colors,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun settingsNav(): NavHostController {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
NavHost(navController, startDestination= "settings"){
|
||||||
|
composable("settings"){SettingsScreen(navController)}
|
||||||
|
composable("licenses"){ LicenseScreen() }
|
||||||
|
composable("about"){ AboutScreen() }
|
||||||
|
}
|
||||||
|
return navController
|
||||||
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsMainScreen(onExit: ()->Unit = {}) {
|
fun SettingsMainScreen(onExit: ()->Unit = {}) {
|
||||||
val nav: NavHostController = rememberNavController()
|
val nav: NavHostController = settingsNav()
|
||||||
SysTheme {
|
SysTheme {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -138,64 +141,155 @@ 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)) {
|
||||||
NavHost(nav, startDestination = "settings") {
|
SettingsScreen()
|
||||||
composable("settings") { SettingsScreen(nav) }
|
|
||||||
composable("about") { AboutScreen() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(navController: NavHostController) {
|
fun SettingsScreen(navController: NavHostController = settingsNav()) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val prefs = defaultPreferences(context)
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
val keyTheme = stringResource(R.string.key_theme)
|
var showEdit by remember { mutableStateOf(false) }
|
||||||
val defaultTheme = stringResource(R.string.system)
|
|
||||||
val keyProjection = stringResource(R.string.key_projection)
|
|
||||||
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 onString = stringResource(R.string.on)
|
|
||||||
|
|
||||||
var theme by remember { mutableStateOf(prefs.getString(keyTheme, defaultTheme)!!) }
|
var theme by remember { mutableStateOf(prefs.getString(context.getString(R.string.key_theme), context.getString(R.string.system))!!) }
|
||||||
var projection by remember { mutableStateOf(prefs.getString(keyProjection, "default")!!) }
|
var projection by remember { mutableStateOf(prefs.getString(context.getString(R.string.key_projection), "default")!!) }
|
||||||
var groups by remember { mutableStateOf(prefs.getString(keyGroup, offString)!!) }
|
var groups by remember { mutableStateOf(prefs.getString(context.getString(R.string.key_group), context.getString(R.string.off))!!) }
|
||||||
var regional by remember { mutableStateOf(prefs.getString(keyRegional, offString)!!) }
|
|
||||||
var cascadeStats by remember { mutableStateOf(prefs.getString(keyCascadeStats, offString)!!) }
|
|
||||||
var touchRadius by remember {
|
|
||||||
mutableIntStateOf(prefs.getInt(keyTouchRadius, DEFAULT_TOUCH_RADIUS))
|
|
||||||
}
|
|
||||||
|
|
||||||
var showGroupDialog by remember { mutableStateOf(false) }
|
if(showEdit)
|
||||||
var showRegionalDialog by remember { mutableStateOf(false) }
|
EditPlaceDialog(true) {
|
||||||
var showLoad by remember { mutableStateOf(false) }
|
showEdit = false
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
if (showGroupDialog) {
|
|
||||||
EditPlaceColorDialog(
|
|
||||||
deleteMode = true,
|
|
||||||
onDismiss = {
|
|
||||||
val g = Data.selected_group
|
val g = Data.selected_group
|
||||||
if (g != null) {
|
if (it && g != null)
|
||||||
Data.visits.reassignAllVisitedToGroup(g.key)
|
Data.visits.reassignAllVisitedToGroup(g.key)
|
||||||
Data.groups.keepOnly(g.key)
|
}
|
||||||
Data.saveData()
|
|
||||||
groups = offString
|
LazyColumn(
|
||||||
prefs.edit { putString(keyGroup, offString) }
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"Theme", style = MaterialTheme.typography.h6,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
)
|
||||||
|
MultiPreference(arrayOf(stringResource(R.string.system),stringResource(R.string.light),stringResource(R.string.dark)), theme) { newTheme ->
|
||||||
|
theme = newTheme
|
||||||
|
prefs.edit { putString(context.getString(R.string.key_theme), newTheme) }
|
||||||
}
|
}
|
||||||
showGroupDialog = false
|
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(context.getString(R.string.key_projection), newProj) }
|
||||||
|
Settings.refreshProjection()
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"Groups",
|
||||||
|
style = MaterialTheme.typography.h6,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
modifier = Modifier.padding(top = 16.dp)
|
||||||
|
)
|
||||||
|
var showDialog by remember{mutableStateOf(false)}
|
||||||
|
if(showDialog){
|
||||||
|
EditPlaceColorDialog(
|
||||||
|
deleteMode = true,
|
||||||
|
onDismiss = {
|
||||||
|
val g = Data.selected_group
|
||||||
|
if (g != null)
|
||||||
|
Data.visits.reassignAllVisitedToGroup(g.key)
|
||||||
|
showDialog = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
MultiPreference(
|
||||||
|
arrayOf(stringResource(R.string.on), stringResource(R.string.off)),
|
||||||
|
groups
|
||||||
|
) { key ->
|
||||||
|
if (key == context.getString(R.string.off)) {
|
||||||
|
showDialog=true
|
||||||
|
}
|
||||||
|
groups = key
|
||||||
|
prefs.edit { putString(context.getString(R.string.key_group), 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showRegionalDialog) {
|
@Composable
|
||||||
|
fun RegionalScreen() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
var selected by remember { mutableStateOf(prefs.getString(context.getString(R.string.key_regional),context.getString(R.string.off))!!)}
|
||||||
|
var regional by remember{ mutableStateOf(prefs.getString(context.getString(R.string.key_regional), context.getString(R.string.off))!!)}
|
||||||
|
var showDialog by remember{mutableStateOf(false)}
|
||||||
|
var showLoad by remember{mutableStateOf(false)}
|
||||||
|
|
||||||
|
if(showDialog)
|
||||||
Dialog(
|
Dialog(
|
||||||
content = {
|
content = {
|
||||||
Column(
|
Column(
|
||||||
@@ -204,27 +298,26 @@ fun SettingsScreen(navController: NavHostController) {
|
|||||||
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 = offString
|
regional= selected
|
||||||
prefs.edit { putString(keyRegional, offString) }
|
prefs.edit {
|
||||||
showRegionalDialog = false
|
putString(
|
||||||
}) {
|
context.getString(R.string.key_regional),
|
||||||
Text(stringResource(R.string.ok))
|
regional
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showDialog=false
|
||||||
|
}){
|
||||||
|
Text(stringResource(R.string.ok))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDismissRequest = { showRegionalDialog = false }
|
onDismissRequest = { showDialog=false }
|
||||||
)
|
)
|
||||||
}
|
if(showLoad){
|
||||||
|
|
||||||
if (showLoad) {
|
|
||||||
Dialog(
|
Dialog(
|
||||||
content = {
|
content = {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
@@ -236,185 +329,41 @@ fun SettingsScreen(navController: NavHostController) {
|
|||||||
onDismissRequest = {}
|
onDismissRequest = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
LazyColumn(
|
MultiPreference(arrayOf(stringResource(R.string.on),stringResource(R.string.off)),regional) { key ->
|
||||||
modifier = Modifier
|
when (key) {
|
||||||
.fillMaxSize()
|
context.getString(R.string.off) -> { showDialog=true
|
||||||
.padding(16.dp)
|
selected=key
|
||||||
.background(MaterialTheme.colors.background)
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.key_theme),
|
|
||||||
style = MaterialTheme.typography.h6,
|
|
||||||
color = MaterialTheme.colors.onBackground,
|
|
||||||
)
|
|
||||||
MultiPreference(
|
|
||||||
arrayOf(
|
|
||||||
stringResource(R.string.system),
|
|
||||||
stringResource(R.string.light),
|
|
||||||
stringResource(R.string.dark)
|
|
||||||
), theme
|
|
||||||
) { newTheme ->
|
|
||||||
theme = newTheme
|
|
||||||
prefs.edit { putString(keyTheme, newTheme) }
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.key_projection),
|
|
||||||
style = MaterialTheme.typography.h6,
|
|
||||||
color = MaterialTheme.colors.onBackground,
|
|
||||||
modifier = Modifier.padding(top = 16.dp)
|
|
||||||
)
|
|
||||||
MultiPreference(
|
|
||||||
arrayOf(
|
|
||||||
stringResource(R.string.mercator),
|
|
||||||
stringResource(R.string.azimuthalequidistant),
|
|
||||||
stringResource(R.string.loximuthal),
|
|
||||||
),
|
|
||||||
projection
|
|
||||||
) { newProj ->
|
|
||||||
projection = newProj
|
|
||||||
prefs.edit { putString(keyProjection, newProj) }
|
|
||||||
Settings.refreshProjection()
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
SettingSwitch(
|
|
||||||
label = stringResource(R.string.key_group),
|
|
||||||
subtitle = stringResource(R.string.key_group_desc),
|
|
||||||
isChecked = groups == onString,
|
|
||||||
onCheckedChange = { isChecked ->
|
|
||||||
if (!isChecked) {
|
|
||||||
Data.selected_group = null
|
|
||||||
showGroupDialog = true
|
|
||||||
} else {
|
|
||||||
groups = onString
|
|
||||||
prefs.edit { putString(keyGroup, onString) }
|
|
||||||
}
|
}
|
||||||
}
|
context.getString(R.string.on) -> {
|
||||||
)
|
regional = key
|
||||||
HorizontalDivider()
|
prefs.edit { putString(context.getString(R.string.key_regional), key) }
|
||||||
}
|
showLoad=true
|
||||||
item {
|
|
||||||
SettingSwitch(
|
|
||||||
label = stringResource(R.string.key_regional),
|
|
||||||
subtitle = stringResource(R.string.key_regional_desc),
|
|
||||||
isChecked = regional == onString,
|
|
||||||
onCheckedChange = { isChecked ->
|
|
||||||
if (isChecked) {
|
|
||||||
regional = onString
|
|
||||||
prefs.edit { putString(keyRegional, onString) }
|
|
||||||
showLoad = true
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
GeoLocImporter.importStates(context, true)
|
GeoLocImporter.importStates(context, true)
|
||||||
}
|
}
|
||||||
showLoad = false
|
showLoad = false
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
showRegionalDialog = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
SettingSwitch(
|
|
||||||
label = stringResource(R.string.pref_cascade_stats),
|
|
||||||
subtitle = stringResource(R.string.pref_cascade_stats_desc),
|
|
||||||
isChecked = cascadeStats == onString,
|
|
||||||
onCheckedChange = { isChecked ->
|
|
||||||
cascadeStats = if (isChecked) onString else offString
|
|
||||||
prefs.edit { putString(keyCascadeStats, cascadeStats) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.pref_touch_radius),
|
|
||||||
style = MaterialTheme.typography.h6,
|
|
||||||
color = MaterialTheme.colors.onBackground,
|
|
||||||
modifier = Modifier.padding(top = 16.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.pref_touch_radius_desc),
|
|
||||||
style = MaterialTheme.typography.body2,
|
|
||||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Slider(
|
|
||||||
value = touchRadius.toFloat(),
|
|
||||||
onValueChange = { touchRadius = it.roundToInt() },
|
|
||||||
onValueChangeFinished = { prefs.edit { putInt(keyTouchRadius, touchRadius) } },
|
|
||||||
valueRange = 0f..MAX_TOUCH_RADIUS.toFloat(),
|
|
||||||
steps = MAX_TOUCH_RADIUS - 1,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.pref_touch_radius_value, touchRadius),
|
|
||||||
style = MaterialTheme.typography.body1,
|
|
||||||
color = MaterialTheme.colors.onBackground,
|
|
||||||
modifier = Modifier.padding(start = 12.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.OpenDocument(),
|
|
||||||
onResult = { uri -> Data.doImport(context, uri) }
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
launcher.launch(arrayOf("*/*"))
|
|
||||||
}, modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_import))
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
Data.doExport(context)
|
|
||||||
}, modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_export))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
PreferenceButton(stringResource(R.string.about)) {
|
|
||||||
if (navController.currentDestination?.route != "about")
|
|
||||||
navController.navigate("about")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MultiPreference(list: Array<String>, selected: String, onSelected: (String) -> Unit) {
|
fun MultiPreference(list: Array<String>, selected: String, onSelected: (String) -> Unit) {
|
||||||
Column(Modifier.padding(vertical = 2.dp)) {
|
Column(Modifier.padding(2.dp)) {
|
||||||
list.forEach { value ->
|
list.map { value ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(42.dp)
|
.height(36.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 = 12.dp),
|
value, modifier = Modifier.padding(start = 8.dp),
|
||||||
style = MaterialTheme.typography.body1,
|
|
||||||
color = MaterialTheme.colors.onBackground,
|
color = MaterialTheme.colors.onBackground,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -429,39 +378,4 @@ fun PreferenceButton(text: String, onClick: () -> Unit) {
|
|||||||
.padding(vertical = 8.dp)) {
|
.padding(vertical = 8.dp)) {
|
||||||
Text(text)
|
Text(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SettingSwitch(
|
|
||||||
label: String,
|
|
||||||
subtitle: String? = null,
|
|
||||||
isChecked: Boolean,
|
|
||||||
onCheckedChange: (Boolean) -> Unit
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onCheckedChange(!isChecked) }
|
|
||||||
.padding(vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text=label,
|
|
||||||
style = MaterialTheme.typography.h6,
|
|
||||||
color = MaterialTheme.colors.onBackground,
|
|
||||||
)
|
|
||||||
if (subtitle != null) {
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = MaterialTheme.typography.body2,
|
|
||||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
androidx.compose.material.Switch(
|
|
||||||
checked = isChecked,
|
|
||||||
onCheckedChange = onCheckedChange
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ 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
|
||||||
@@ -33,15 +32,12 @@ 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.NO_GROUP
|
import net.helcel.beans.helper.Settings.isRegional
|
||||||
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)
|
||||||
@@ -50,7 +46,7 @@ private val MODE_LIST = listOf(LocType.WORLD, LocType.COUNTRY, LocType.STATE)
|
|||||||
fun StatsScreen(
|
fun StatsScreen(
|
||||||
onExit: ()-> Unit
|
onExit: ()-> Unit
|
||||||
) {
|
) {
|
||||||
val modes = if (Settings.isRegional(LocalContext.current)) MODE_LIST else MODE_LIST.take(2)
|
val modes = if (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) }
|
||||||
|
|
||||||
@@ -61,7 +57,7 @@ fun StatsScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically){
|
Row(verticalAlignment = Alignment.CenterVertically){
|
||||||
Text(text=stringResource(R.string.action_stat), modifier = Modifier.weight(1f))
|
Text(text=stringResource(R.string.action_edit), modifier = Modifier.weight(1f))
|
||||||
Button(onClick = { countMode = !countMode }) {
|
Button(onClick = { countMode = !countMode }) {
|
||||||
Text(if (countMode) "Count" else "Area")
|
Text(if (countMode) "Count" else "Area")
|
||||||
}
|
}
|
||||||
@@ -120,82 +116,26 @@ 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 context = LocalContext.current
|
||||||
val isCascadeStats = remember { Settings.isCascadeStats(context) }
|
|
||||||
val visits by Data.visits.visitsFlow.collectAsState()
|
|
||||||
|
|
||||||
val continents = remember { World.WWW.children.toList() }
|
val visited = remember(group, activeMode) {
|
||||||
val countries = remember {
|
Data.visits.getVisitedByValue(group.key)
|
||||||
World.WWW.children.flatMap {
|
|
||||||
if (it is Country) listOf(it) else it.children
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val visitedItems = remember(group, activeMode, isCascadeStats, visits) {
|
val count = when (activeMode) {
|
||||||
val list = when (activeMode) {
|
LocType.WORLD -> World.WWW.children.filter { it.code in visited }.size
|
||||||
LocType.WORLD -> continents
|
LocType.COUNTRY -> World.WWW.children.flatMap { it.children.filter { c -> c.code in visited } }.size
|
||||||
LocType.COUNTRY -> countries
|
LocType.STATE -> World.WWW.children.flatMap { itc->itc.children.flatMap { it.children.filter { it.code in visited } } }.size
|
||||||
LocType.STATE -> countries.flatMap { it.children }
|
else -> 0
|
||||||
else -> emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getExplicitColor(l: GeoLoc): Int? {
|
|
||||||
val c = visits.getOrDefault(l.code, 0)
|
|
||||||
return if (c != NO_GROUP && c != AUTO_GROUP) c else null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getEffectiveGroup(l: GeoLoc): Int {
|
|
||||||
getExplicitColor(l)?.let { return it }
|
|
||||||
|
|
||||||
if (isCascadeStats) {
|
|
||||||
// Parent -> Child (closest explicit ancestor)
|
|
||||||
val country = countries.find { it.children.contains(l) }
|
|
||||||
val countryColor = country?.let { getExplicitColor(it) }
|
|
||||||
if (countryColor != null) return countryColor
|
|
||||||
|
|
||||||
val continent = continents.find {
|
|
||||||
it.children.contains(l) || (country != null && it.children.contains(country))
|
|
||||||
}
|
|
||||||
val continentColor = continent?.let { getExplicitColor(it) }
|
|
||||||
if (continentColor != null) return continentColor
|
|
||||||
|
|
||||||
val worldColor = getExplicitColor(World.WWW)
|
|
||||||
if (worldColor != null) return worldColor
|
|
||||||
|
|
||||||
// Child -> Parent (most common explicit descendant)
|
|
||||||
val descendants = mutableListOf<GeoLoc>()
|
|
||||||
fun collect(curr: GeoLoc) {
|
|
||||||
curr.children.forEach {
|
|
||||||
descendants.add(it)
|
|
||||||
collect(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
collect(l)
|
|
||||||
val descendantColors = descendants.mapNotNull { getExplicitColor(it) }
|
|
||||||
if (descendantColors.isNotEmpty()) {
|
|
||||||
return descendantColors.groupBy { it }.maxByOrNull { it.value.size }?.key ?: AUTO_GROUP
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (l.type == LocType.WORLD || l.type == LocType.COUNTRY) {
|
|
||||||
val descendants = mutableListOf<GeoLoc>()
|
|
||||||
fun collect(curr: GeoLoc) {
|
|
||||||
curr.children.forEach { descendants.add(it); collect(it) }
|
|
||||||
}
|
|
||||||
collect(l)
|
|
||||||
val dc = descendants.mapNotNull { getExplicitColor(it) }
|
|
||||||
if (dc.isNotEmpty()) return dc.groupBy { it }.maxByOrNull { it.value.size }?.key ?: AUTO_GROUP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AUTO_GROUP
|
|
||||||
}
|
|
||||||
|
|
||||||
list.filter { getEffectiveGroup(it) == group.key }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val count = visitedItems.size
|
val area = when (activeMode) {
|
||||||
val area = visitedItems.sumOf { it.area.toLong() }
|
LocType.WORLD -> World.WWW.children.filter { it.code in visited }.sumOf { it.area }
|
||||||
|
LocType.COUNTRY -> World.WWW.children.flatMap { it.children.filter { c -> c.code in visited } }.sumOf { it.area }
|
||||||
|
LocType.STATE -> World.WWW.children.flatMap { it.children.flatMap { it.children.filter { it.code in visited } } }.sumOf { it.area }
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
val displayValue = if (countMode) count.toString() else stringResource(R.string.number_with_unit, area.toInt(), "km²")
|
val displayValue = if (countMode) count.toString() else context.getString(R.string.number_with_unit, area, "km²")
|
||||||
|
|
||||||
val backgroundColor = group.color.color
|
val backgroundColor = group.color.color
|
||||||
val textColor = getContrastColor(backgroundColor)
|
val textColor = getContrastColor(backgroundColor)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
@@ -55,89 +54,27 @@ fun EditPlaceScreenPreview(){
|
|||||||
EditPlaceScreen(Group.EEE)
|
EditPlaceScreen(Group.EEE)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun syncVisited(loc: GeoLoc?=World.WWW){
|
||||||
* Applies a place straight away where a single group makes the choice obvious,
|
|
||||||
* as tapping the checkbox does. Returns false when the group still has to be
|
|
||||||
* picked from the colour dialog.
|
|
||||||
*/
|
|
||||||
fun applyDirectVisit(ctx: Context, loc: GeoLoc): Boolean {
|
|
||||||
Data.selected_geoloc = loc
|
|
||||||
Data.selected_group = null
|
|
||||||
if (Data.groups.size() != 1 || !Settings.isSingleGroup(ctx)) return false
|
|
||||||
val current = Data.visits.getVisited(loc)
|
|
||||||
Data.visits.setVisited(
|
|
||||||
loc,
|
|
||||||
if (current == NO_GROUP || current == AUTO_GROUP) Data.groups.getUniqueEntry()!!.key
|
|
||||||
else if (loc.children.any { Data.visits.getVisited(it) != NO_GROUP }) AUTO_GROUP
|
|
||||||
else NO_GROUP,
|
|
||||||
)
|
|
||||||
syncVisited()
|
|
||||||
Data.saveData()
|
|
||||||
Data.selected_geoloc = null
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stores whatever the colour dialog came back with. */
|
|
||||||
fun commitVisitDialog(cleared: Boolean) {
|
|
||||||
if (cleared) {
|
|
||||||
Data.visits.setVisited(Data.selected_geoloc, NO_GROUP)
|
|
||||||
syncVisited()
|
|
||||||
Data.saveData()
|
|
||||||
}
|
|
||||||
if (Data.selected_group != null && Data.selected_geoloc != null) {
|
|
||||||
Data.visits.setVisited(Data.selected_geoloc, Data.selected_group!!.key)
|
|
||||||
syncVisited()
|
|
||||||
Data.saveData()
|
|
||||||
}
|
|
||||||
Data.selected_geoloc = null
|
|
||||||
Data.selected_group = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun syncVisited(loc: GeoLoc?=World.WWW): Boolean {
|
|
||||||
var changed = false
|
|
||||||
loc?.children?.forEach { tt ->
|
loc?.children?.forEach { tt ->
|
||||||
tt.children.forEach {itc->
|
tt.children.forEach {itc->
|
||||||
if(Data.visits.getVisited(itc) in listOf(AUTO_GROUP,NO_GROUP)) {
|
if(Data.visits.getVisited(itc) in listOf(AUTO_GROUP,NO_GROUP)) {
|
||||||
val newState = if(itc.children.any { c -> Data.visits.getVisited(c) != NO_GROUP })
|
if(itc.children.any { itcc -> Data.visits.getVisited(itcc) != NO_GROUP })
|
||||||
AUTO_GROUP
|
Data.visits.setVisited(itc, AUTO_GROUP)
|
||||||
else
|
else
|
||||||
NO_GROUP
|
Data.visits.setVisited(itc, NO_GROUP)
|
||||||
|
|
||||||
if (Data.visits.getVisited(itc) != newState) {
|
|
||||||
Data.visits.setVisited(itc, newState)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(Data.visits.getVisited(tt) in listOf(AUTO_GROUP,NO_GROUP)) {
|
if(Data.visits.getVisited(tt) in listOf(AUTO_GROUP,NO_GROUP)) {
|
||||||
val newState = if(tt.children.any { itc -> Data.visits.getVisited(itc) != NO_GROUP })
|
if(tt.children.any { itc -> Data.visits.getVisited(itc) != NO_GROUP })
|
||||||
AUTO_GROUP
|
Data.visits.setVisited(tt, AUTO_GROUP)
|
||||||
else
|
else
|
||||||
NO_GROUP
|
Data.visits.setVisited(tt, NO_GROUP)
|
||||||
|
|
||||||
if (Data.visits.getVisited(tt) != newState) {
|
|
||||||
Data.visits.setVisited(tt, newState)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sync World from Continents
|
|
||||||
if (loc != null && Data.visits.getVisited(loc) in listOf(AUTO_GROUP, NO_GROUP)) {
|
|
||||||
val newState = if(loc.children.any { Data.visits.getVisited(it) != NO_GROUP })
|
|
||||||
AUTO_GROUP
|
|
||||||
else
|
|
||||||
NO_GROUP
|
|
||||||
if (Data.visits.getVisited(loc) != newState) {
|
|
||||||
Data.visits.setVisited(loc, newState)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
||||||
val visits by Data.visits.visitsFlow.collectAsState()
|
|
||||||
var showEdit by remember { mutableStateOf(false) }
|
var showEdit by remember { mutableStateOf(false) }
|
||||||
val tabs : SnapshotStateList<GeoLoc> = remember { mutableStateListOf(loc) }
|
val tabs : SnapshotStateList<GeoLoc> = remember { mutableStateListOf(loc) }
|
||||||
val ctx = LocalContext.current
|
val ctx = LocalContext.current
|
||||||
@@ -147,12 +84,7 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
|||||||
selectedTab = tabs.lastIndex
|
selectedTab = tabs.lastIndex
|
||||||
}
|
}
|
||||||
SideEffect {
|
SideEffect {
|
||||||
// visits is used to trigger sync whenever data changes
|
syncVisited()
|
||||||
if (visits.isNotEmpty() || true) {
|
|
||||||
if (syncVisited()) {
|
|
||||||
Data.saveData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
BackHandler {
|
BackHandler {
|
||||||
if (tabs.size > 1) tabs.removeAt(tabs.lastIndex)
|
if (tabs.size > 1) tabs.removeAt(tabs.lastIndex)
|
||||||
@@ -161,25 +93,39 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
|||||||
if(showEdit)
|
if(showEdit)
|
||||||
EditPlaceDialog(false) {
|
EditPlaceDialog(false) {
|
||||||
showEdit = false
|
showEdit = false
|
||||||
commitVisitDialog(it)
|
if (it) {
|
||||||
|
Data.visits.setVisited(Data.selected_geoloc, NO_GROUP)
|
||||||
|
Data.saveData()
|
||||||
|
|
||||||
|
if (Data.selected_geoloc!=null && Data.selected_geoloc!!.children.any { itc-> Data.visits.getVisited(itc) != NO_GROUP }) {
|
||||||
|
Data.clearing_geoloc = Data.selected_geoloc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Data.selected_group != null && Data.selected_geoloc != null) {
|
||||||
|
Data.visits.setVisited(Data.selected_geoloc, Data.selected_group!!.key)
|
||||||
|
Data.saveData()
|
||||||
|
}
|
||||||
|
Data.selected_geoloc = null
|
||||||
|
Data.selected_group = null
|
||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
val currentTab = tabs.getOrNull(selectedTab) ?: return@Column
|
val currentTab = tabs.getOrNull(selectedTab) ?: return@Column
|
||||||
TabRow(
|
TabRow(
|
||||||
selectedTabIndex = min(tabs.lastIndex, selectedTab),
|
selectedTabIndex = min(tabs.lastIndex, selectedTab),
|
||||||
) {
|
) {
|
||||||
tabs.forEachIndexed { index, tab ->
|
tabs.forEachIndexed { index, tab ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = selectedTab == index,
|
selected = selectedTab == index,
|
||||||
onClick = {
|
onClick = {
|
||||||
while (tabs.size > index + 1)
|
while (tabs.size > index + 1)
|
||||||
tabs.removeAt(tabs.lastIndex)
|
tabs.removeAt(tabs.lastIndex)
|
||||||
},
|
},
|
||||||
text = { Text(tab.fullName) }
|
text = { Text(tab.fullName) }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
@@ -191,7 +137,20 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
|||||||
tabs.add(loc)
|
tabs.add(loc)
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
if (!applyDirectVisit(ctx, loc)) showEdit = true
|
Data.selected_geoloc = loc
|
||||||
|
if (Data.groups.size() == 1 && Settings.isSingleGroup(ctx)) {
|
||||||
|
Data.visits.setVisited(Data.selected_geoloc,
|
||||||
|
if (it != ToggleableState.On) Data.groups.getUniqueEntry()!!.key
|
||||||
|
else if(Data.selected_geoloc?.children?.any{ itc->
|
||||||
|
Data.visits.getVisited(itc)!= NO_GROUP } == true) AUTO_GROUP
|
||||||
|
else NO_GROUP
|
||||||
|
)
|
||||||
|
Data.selected_group = null
|
||||||
|
} else {
|
||||||
|
Data.selected_group = null
|
||||||
|
showEdit=true
|
||||||
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -205,7 +164,7 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
|||||||
fun GeoLocRow(
|
fun GeoLocRow(
|
||||||
loc: GeoLoc,
|
loc: GeoLoc,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onToggle: () -> Unit,
|
onCheckedChange: (ToggleableState) -> 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) {
|
||||||
@@ -244,7 +203,7 @@ fun GeoLocRow(
|
|||||||
|
|
||||||
TriStateCheckbox(
|
TriStateCheckbox(
|
||||||
state = checked,
|
state = checked,
|
||||||
onClick = onToggle,
|
onClick= { onCheckedChange(checked) },
|
||||||
colors = CheckboxDefaults.colors(
|
colors = CheckboxDefaults.colors(
|
||||||
checkedColor = color,
|
checkedColor = color,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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.rememberLibraries
|
||||||
|
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 = rememberLibraries(R.raw.aboutlibraries)
|
||||||
|
SysTheme {
|
||||||
|
LibrariesContainer(
|
||||||
|
libraries = libraries.value,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
colors = DefaultLibraryColors(
|
||||||
|
backgroundColor = MaterialTheme.colors.background,
|
||||||
|
contentColor = 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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
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,7 +12,6 @@ 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")))
|
||||||
@@ -27,7 +26,6 @@ object GeoLocImporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GeoLocTree.rebuild()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearStates() {
|
fun clearStates() {
|
||||||
@@ -41,7 +39,6 @@ object GeoLocImporter {
|
|||||||
}
|
}
|
||||||
country.children.clear()
|
country.children.clear()
|
||||||
}
|
}
|
||||||
GeoLocTree.rebuild()
|
|
||||||
Data.saveData()
|
Data.saveData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package net.helcel.beans.countries
|
|||||||
|
|
||||||
import net.helcel.beans.countries.Country.*
|
import net.helcel.beans.countries.Country.*
|
||||||
|
|
||||||
@Suppress("RedundantSuppression", "SpellCheckingInspection", "unused")
|
|
||||||
enum class Group(override val fullName: String, override val children: Set<GeoLoc>) : GeoLoc {
|
enum class Group(override val fullName: String, override val children: Set<GeoLoc>) : GeoLoc {
|
||||||
|
|
||||||
EEE(
|
EEE(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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
|
||||||
@@ -22,6 +23,7 @@ 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()
|
||||||
@@ -29,7 +31,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 = defaultPreferences(ctx)
|
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(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)
|
||||||
@@ -41,14 +43,14 @@ object Data {
|
|||||||
if (groups.size() == 0) {
|
if (groups.size() == 0) {
|
||||||
groups.setGroup(DEFAULT_GROUP, "Visited",
|
groups.setGroup(DEFAULT_GROUP, "Visited",
|
||||||
ContextCompat.getColor(ctx, R.color.blue).toDrawable())
|
ContextCompat.getColor(ctx, R.color.blue).toDrawable())
|
||||||
|
saveData()
|
||||||
}
|
}
|
||||||
saveData()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveData() {
|
fun saveData() {
|
||||||
if(groups.id != visits.id) return
|
if(groups.id != visits.id) return
|
||||||
val id = groups.id
|
val id = groups.id
|
||||||
sharedPreferences.edit(commit=true) {
|
sharedPreferences.edit {
|
||||||
putString("groups_$id", groupsSerial.writeTo(groups))
|
putString("groups_$id", groupsSerial.writeTo(groups))
|
||||||
putString("visits_$id", visitsSerial.writeTo(visits))
|
putString("visits_$id", visitsSerial.writeTo(visits))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package net.helcel.beans.helper
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.drawable.ColorDrawable
|
import android.graphics.drawable.ColorDrawable
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Serializer
|
import kotlinx.serialization.Serializer
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -25,66 +24,53 @@ const val AUTO_GROUP = -1
|
|||||||
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Groups(val id: Int, @SerialName("grps") private val groups: HashMap<Int, Group>) {
|
class Groups(val id: Int, private val grps: HashMap<Int, Group>) {
|
||||||
@kotlinx.serialization.Transient
|
@kotlinx.serialization.Transient
|
||||||
private val _groupsFlow = MutableStateFlow<List<Group>>(groups.values.toList())
|
private val _groupsFlow = MutableStateFlow<List<Group>>(grps.values.toList())
|
||||||
@kotlinx.serialization.Transient
|
@kotlinx.serialization.Transient
|
||||||
val groupsFlow: StateFlow<List<Group>> = _groupsFlow.asStateFlow()
|
val groupsFlow: StateFlow<List<Group>> = _groupsFlow.asStateFlow()
|
||||||
|
|
||||||
fun setGroup(key: Int, name: String, col: ColorDrawable) {
|
fun setGroup(key: Int, name: String, col: ColorDrawable) {
|
||||||
val old = groups[key]
|
grps[key] = Group(key, name, col)
|
||||||
if (old != null && old.name == name && old.color.color == col.color) return
|
_groupsFlow.value = grps.values.toList()
|
||||||
groups[key] = Group(key, name, col)
|
|
||||||
updateFlow()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteGroup(key: Int) {
|
fun deleteGroup(key: Int) {
|
||||||
groups.remove(key)
|
grps.remove(key)
|
||||||
updateFlow()
|
_groupsFlow.value = grps.values.toList()
|
||||||
}
|
|
||||||
|
|
||||||
fun keepOnly(key: Int) {
|
|
||||||
val keep = groups[key] ?: return
|
|
||||||
groups.clear()
|
|
||||||
groups[key] = keep
|
|
||||||
updateFlow()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateFlow() {
|
|
||||||
_groupsFlow.value = groups.values.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getGroupFromKey(key: Int): Group {
|
fun getGroupFromKey(key: Int): Group {
|
||||||
return groups.getOrDefault(key, EmptyGroup())
|
return grps.getOrDefault(key, EmptyGroup())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun genKey(): Int {
|
fun genKey(): Int {
|
||||||
val key = rnd.nextInt()
|
val key = rnd.nextInt()
|
||||||
if (groups.containsKey(key) || key in listOf(NO_GROUP, DEFAULT_GROUP, AUTO_GROUP)) return genKey()
|
if (grps.containsKey(key) || key in listOf(NO_GROUP, DEFAULT_GROUP, AUTO_GROUP)) return genKey()
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
fun size(): Int {
|
fun size(): Int {
|
||||||
return groups.size
|
return grps.size
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getUniqueEntry(): Group? {
|
fun getUniqueEntry(): Group? {
|
||||||
assert(size() == 1)
|
assert(size() == 1)
|
||||||
return if (groups.size == 1) {
|
return if (grps.size == 1) {
|
||||||
groups[groups.keys.first()]
|
grps[grps.keys.first()]
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getGroupFromPos(pos: Int): Pair<Int, Group> {
|
fun getGroupFromPos(pos: Int): Pair<Int, Group> {
|
||||||
if(groups.keys.isEmpty()) return Pair(NO_GROUP,Group(NO_GROUP,"-"))
|
if(grps.keys.isEmpty()) return Pair(NO_GROUP,Group(NO_GROUP,"-"))
|
||||||
val key = groups.keys.toList()[pos]
|
val key = grps.keys.toList()[pos]
|
||||||
return Pair(key, getGroupFromKey(key))
|
return Pair(key, getGroupFromKey(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findGroupPos(key: Int): Int {
|
fun findGroupPos(key: Int): Int {
|
||||||
return groups.keys.toList().indexOf(key)
|
return grps.keys.toList().indexOf(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
class EmptyGroup : Group(0, "")
|
class EmptyGroup : Group(0, "")
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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,26 +2,17 @@ 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 = defaultPreferences(ctx)
|
sp = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isSingleGroup(ctx: Context): Boolean {
|
fun isSingleGroup(ctx: Context): Boolean {
|
||||||
@@ -38,21 +29,6 @@ object Settings {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How far around a tap, in dp, the map looks for other places. Anything
|
|
||||||
* within it is offered as a candidate instead of painting straight away.
|
|
||||||
*/
|
|
||||||
fun getTouchRadius(ctx: Context): Float {
|
|
||||||
return sp.getInt(ctx.getString(R.string.key_touch_radius), DEFAULT_TOUCH_RADIUS).toFloat()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isCascadeStats(ctx: Context): Boolean {
|
|
||||||
return getBooleanValue(
|
|
||||||
ctx,
|
|
||||||
sp.getString(ctx.getString(R.string.key_cascade_stats), ctx.getString(R.string.off))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getStatPref(ctx: Context): String? {
|
fun getStatPref(ctx: Context): String? {
|
||||||
return sp.getString(
|
return sp.getString(
|
||||||
ctx.getString(R.string.key_stats),
|
ctx.getString(R.string.key_stats),
|
||||||
|
|||||||
@@ -19,25 +19,24 @@ class Visits(val id: Int, private val locs: HashMap<String, Int>) {
|
|||||||
val visitsFlow: StateFlow<Map<String,Int>> = _visitsFlow
|
val visitsFlow: StateFlow<Map<String,Int>> = _visitsFlow
|
||||||
|
|
||||||
fun setVisited(key: GeoLoc?, b: Int) {
|
fun setVisited(key: GeoLoc?, b: Int) {
|
||||||
if (key == null || locs[key.code] == b)
|
if (key == null)
|
||||||
return
|
return
|
||||||
|
_visitsFlow.value = _visitsFlow.value.toMutableMap().apply {
|
||||||
|
this[key.code] = b
|
||||||
|
}
|
||||||
locs[key.code] = b
|
locs[key.code] = b
|
||||||
updateFlow()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateFlow() {
|
|
||||||
_visitsFlow.value = locs.toMap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteVisited(key: Int) {
|
fun deleteVisited(key: Int) {
|
||||||
val keysToDelete = locs
|
val keysToDelete = locs
|
||||||
.filter { it.value == key }
|
.filter { it.value == key }
|
||||||
.map { it.key }
|
.map { it.key }
|
||||||
if (keysToDelete.isEmpty()) return
|
_visitsFlow.value = _visitsFlow.value.toMutableMap().apply {
|
||||||
|
keysToDelete.forEach { this.remove(it)}
|
||||||
|
}
|
||||||
keysToDelete.forEach {
|
keysToDelete.forEach {
|
||||||
locs.remove(it)
|
locs.remove(it)
|
||||||
}
|
}
|
||||||
updateFlow()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getVisited(key: GeoLoc): Int {
|
fun getVisited(key: GeoLoc): Int {
|
||||||
@@ -61,19 +60,13 @@ class Visits(val id: Int, private val locs: HashMap<String, Int>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun reassignAllVisitedToGroup(group: Int) {
|
fun reassignAllVisitedToGroup(group: Int) {
|
||||||
var changed = false
|
|
||||||
val keys = locs.filter { (_, grp) ->
|
val keys = locs.filter { (_, grp) ->
|
||||||
grp !in listOf(NO_GROUP, AUTO_GROUP)
|
grp !in listOf(NO_GROUP, AUTO_GROUP)
|
||||||
}.keys
|
}.keys
|
||||||
keys.forEach {
|
keys.forEach {
|
||||||
if (locs[it] != group) {
|
locs[it] = group
|
||||||
locs[it] = group
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed) {
|
|
||||||
updateFlow()
|
|
||||||
}
|
}
|
||||||
|
_visitsFlow.value = locs
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="128"
|
|
||||||
android:viewportHeight="128">
|
|
||||||
<group android:scaleX="0.8918919"
|
|
||||||
android:scaleY="0.8918919"
|
|
||||||
android:translateX="6.918919"
|
|
||||||
android:translateY="6.918919">
|
|
||||||
<group
|
|
||||||
android:scaleX="1.25"
|
|
||||||
android:scaleY="1.25"
|
|
||||||
android:translateX="18"
|
|
||||||
android:translateY="18">
|
|
||||||
<path
|
|
||||||
android:pathData="M56.803,45.75c0.599,-0.299 1.157,-0.672 1.663,-1.11 0.804,-0.698 1.463,-1.546 1.94,-2.497 0.599,-1.303 0.883,-2.728 0.829,-4.161 0.034,-1.498 -0.154,-2.994 -0.558,-4.437 -0.451,-1.645 -1.104,-3.229 -1.944,-4.714 -0.967,-2.141 -2.28,-4.108 -3.887,-5.822 -1.242,-1.326 -2.757,-2.366 -4.44,-3.048 -0.877,-0.372 -1.821,-0.56 -2.774,-0.553 -1.402,0.074 -2.729,0.697 -3.882,1.389 -1.285,0.81 -2.244,1.735 -2.771,3.053 -0.739,2.362 0.827,4.821 1.113,5.269 0.003,0.005 0.187,0.281 0.555,0.832 1.512,2.264 1.589,2.358 1.666,2.495 0.646,1.221 1.114,2.528 1.389,3.882 1.043,4.001 1.565,6.001 2.223,6.932 1.208,1.71 3.455,3.414 5.826,3.325 1.059,-0.088 2.093,-0.371 3.05,-0.835Z"
|
|
||||||
android:strokeWidth="2"
|
|
||||||
android:fillColor="@color/white"
|
|
||||||
android:strokeColor="#000000"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M22,41c-1.54,0.554 -2.83,1.642 -3.636,3.066 -0.714,1.365 -0.957,2.928 -0.693,4.445 0.178,1.247 0.632,2.439 1.329,3.488 2.032,3.228 5.383,4.423 7,5 1.613,0.561 3.295,0.897 5,1 2.387,0.205 6.923,0.535 11,-2 1.287,-0.665 2.335,-1.713 3,-3 0.661,-1.601 0.661,-3.399 0,-5 -0.389,-1.156 -1.121,-2.165 -2.099,-2.894 -0.919,-0.599 -1.974,-0.956 -3.069,-1.039 -2.057,-0.313 -2.756,0.139 -5.014,0.057 -1.271,-0.019 -2.534,-0.214 -3.753,-0.577 -3.036,-0.95 -3.33,-2.457 -5.889,-2.829 -1.066,-0.158 -2.155,-0.061 -3.177,0.282Z"
|
|
||||||
android:strokeWidth="2"
|
|
||||||
android:fillColor="@color/white"
|
|
||||||
android:strokeColor="#000000"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M12.7,26.975c1.758,-3.604 5.311,-3.191 9.58,-7.091 2.701,-2.469 2.858,-4.078 5.613,-5.202 1.407,-0.644 2.984,-0.819 4.498,-0.5 2.053,0.475 3.761,1.891 4.61,3.819 1.116,2.742 -0.314,5.437 -1.398,7.482 -0.951,1.689 -2.167,3.215 -3.602,4.518 -1.479,1.552 -3.161,2.898 -5,4 -2.629,1.541 -6.332,3.711 -9.989,2.43 -1.481,-0.548 -2.768,-1.521 -3.699,-2.796 -1.496,-1.906 -1.735,-4.512 -0.612,-6.659Z"
|
|
||||||
android:strokeWidth="2"
|
|
||||||
android:fillColor="@color/white"
|
|
||||||
android:strokeColor="#000000"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
@@ -2,5 +2,4 @@
|
|||||||
<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_monochrome"/>
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<style name="Theme.Beans" parent="android:Theme.Material.NoActionBar">
|
|
||||||
<!-- Status bar color in dark mode -->
|
|
||||||
<item name="android:statusBarColor">@color/blue</item>
|
|
||||||
<!-- Window background in dark mode -->
|
|
||||||
<item name="android:windowBackground">@color/darkgray</item>
|
|
||||||
<item name="android:windowLightStatusBar">false</item>
|
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<style name="Theme.Beans" parent="android:Theme.Material.Light.NoActionBar">
|
|
||||||
<item name="android:statusBarColor">@color/blue</item>
|
|
||||||
<item name="android:windowBackground">@color/blue</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -13,37 +13,20 @@
|
|||||||
<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 mode</string>
|
<string name="key_regional">Regional</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,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<style name="Theme.Beans" parent="android:Theme.Material.Light.NoActionBar">
|
|
||||||
<!-- Status bar color -->
|
|
||||||
<item name="android:statusBarColor">@color/blue</item>
|
|
||||||
<!-- Window background - shows through statusBarsPadding() area -->
|
|
||||||
<item name="android:windowBackground">@color/blue</item>
|
|
||||||
<item name="android:windowLightStatusBar">false</item>
|
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application' version '9.4.0' apply false
|
id 'com.android.application' version '9.1.0' apply false
|
||||||
id 'com.android.library' version '9.3.2' apply false
|
id 'com.android.library' version '9.1.0' apply false
|
||||||
id 'org.jetbrains.kotlin.android' version '2.4.10' apply false
|
id 'org.jetbrains.kotlin.android' version '2.3.10' apply false
|
||||||
}
|
}
|
||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/node
|
#!/bin/node
|
||||||
|
|
||||||
import {readFileSync,existsSync} from 'fs'
|
import {readFileSync, existsSync} from 'fs'
|
||||||
import area from '@turf/area'
|
import area from '@turf/area'
|
||||||
import * as turf from '@turf/turf'
|
import * as turf from '@turf/turf'
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ const countries =
|
|||||||
|
|
||||||
|
|
||||||
const groups = {
|
const groups = {
|
||||||
"EEE":["ALA","ALB","AND","AUT","BLR","BEL","BIH","BGR","HRV","CYP","CZE","DNK","EST","FIN","FRA","DEU","GIB","GGY","GRC","HUN","ISL","IRL","IMN","JEY","ITA","KAZ","XKO","LVA","LIE","LTU","LUX","MLT","MDA","MCO","MNE","NLD","MKD","NOR","SJM","POL","PRT","ROU","RUS","SMR","SRB","SVK","SVN","ESP","SWE","CHE","UKR","GBR","VAT","XAD"],
|
"EEE":["ALB","AND","AUT","BLR","BEL","BIH","BGR","HRV","CYP","CZE","DNK","EST","FIN","FRA","DEU","GRC","HUN","ISL","IRL","ITA","KAZ","XKO","LVA","LIE","LTU","LUX","MLT","MDA","MCO","MNE","NLD","MKD","NOR","POL","PRT","ROU","RUS","SMR","SRB","SVK","SVN","ESP","SWE","CHE","UKR","GBR","VAT","XAD"],
|
||||||
"ABB":["AFG","ARM","AZE","BHR","BGD","BTN","BRN","KHM","CHN","GEO","HKG","IND","IDN","IRN","IRQ","ISR","JPN","JOR","KWT","KGZ","LAO","LBN","MAC","MYS","MDV","MNG","MMR","NPL","PRK","OMN","PAK","PSE","PHL","QAT","SAU","SGP","KOR","LKA","SYR","TWN","TJK","THA","TLS","TUR","TKM","ARE","UZB","VNM","YEM","ZNC"],
|
"ABB":["AFG","ARM","AZE","BHR","BGD","BTN","BRN","KHM","CHN","GEO","HKG","IND","IDN","IRN","IRQ","ISR","JPN","JOR","KWT","KGZ","LAO","LBN","MAC","MYS","MDV","MNG","MMR","NPL","PRK","OMN","PAK","PSE","PHL","QAT","SAU","SGP","KOR","LKA","SYR","TWN","TJK","THA","TLS","TUR","TKM","ARE","UZB","VNM","YEM","ZNC"],
|
||||||
"FFF":["DZA","AGO","BDI","BEN","BWA","BFA","BDI","CPV","CMR","CAF","TCD","COM","COG","COD","CIV","DJI","EGY","GNQ","ERI","SWZ","ETH","GAB","GMB","GHA","GIN","GNB","KEN","LSO","LBR","LBY","MDG","MWI","MLI","MRT","MUS","MYT","MAR","MOZ","NAM","NER","NGA","COD","REU","RWA","STP","SEN","SYC","SLE","SOM","ZAF","SSD","SHN","SDN","TZA","TGO","TUN","UGA","COD","ZMB","ZWE","ESH"],
|
"FFF":["DZA","AGO","BDI","BEN","BWA","BFA","BDI","CPV","CMR","CAF","TCD","COM","COG","COD","CIV","DJI","EGY","GNQ","ERI","SWZ","ETH","GAB","GMB","GHA","GIN","GNB","KEN","LSO","LBR","LBY","MDG","MWI","MLI","MRT","MUS","MYT","MAR","MOZ","NAM","NER","NGA","COD","REU","RWA","STP","SEN","SYC","SLE","SOM","ZAF","SSD","SHN","SDN","TZA","TGO","TUN","UGA","COD","ZMB","ZWE","ESH"],
|
||||||
"NNN":["ABW","AIA","ATG","BHS","BRB","BLZ","BMU","VGB","CAN","CYM","CRI","CUB","CUW","DMA","DOM","SLV","GRL","GRD","GLP","GTM","HTI","HND","JAM","MTQ","MEX","MSR","ANT","CUW","NIC","PAN","PRI","KNA","LCA","MAF","SPM","VCT","TTO","TCA","USA","XCL"],
|
"NNN":["ABW","AIA","ATG","BHS","BRB","BLZ","BMU","VGB","CAN","CYM","CRI","CUB","CUW","DMA","DOM","SLV","GRL","GRD","GLP","GTM","HTI","HND","JAM","MTQ","MEX","MSR","ANT","CUW","NIC","PAN","PRI","KNA","LCA","MAF","SPM","VCT","TTO","TCA","USA","XCL"],
|
||||||
@@ -32,6 +32,7 @@ const groups = {
|
|||||||
"UUU":["ASM","AUS","COK","FJI","PYF","GUM","KIR","MHL","FSM","NRU","NCL","NZL","NIU","NFK","MNP","PLW","PNG","PCN","SLB","TKL","TON","TUV","VUT","WLF"],
|
"UUU":["ASM","AUS","COK","FJI","PYF","GUM","KIR","MHL","FSM","NRU","NCL","NZL","NIU","NFK","MNP","PLW","PNG","PCN","SLB","TKL","TON","TUV","VUT","WLF"],
|
||||||
"XXX":[
|
"XXX":[
|
||||||
"ATA", // Antarctica: not in any other region
|
"ATA", // Antarctica: not in any other region
|
||||||
|
"ALA",// Åland Islands: an autonomous region of Finland, but not a member of the EU or UN
|
||||||
"BES",// Bonaire, Sint Eustatius and Saba: special municipalities of the Netherlands in the Caribbean
|
"BES",// Bonaire, Sint Eustatius and Saba: special municipalities of the Netherlands in the Caribbean
|
||||||
"BVT",// Bouvet Island: an uninhabited territory of Norway in the South Atlantic
|
"BVT",// Bouvet Island: an uninhabited territory of Norway in the South Atlantic
|
||||||
"IOT",// British Indian Ocean Territory: a British overseas territory in the Indian Ocean
|
"IOT",// British Indian Ocean Territory: a British overseas territory in the Indian Ocean
|
||||||
@@ -39,11 +40,16 @@ const groups = {
|
|||||||
"CCK",// Cocos (Keeling) Islands: an Australian external territory in the Indian Ocean
|
"CCK",// Cocos (Keeling) Islands: an Australian external territory in the Indian Ocean
|
||||||
"FRO",// Faroe Islands: an autonomous region of Denmark
|
"FRO",// Faroe Islands: an autonomous region of Denmark
|
||||||
"ATF",// French Southern and Antarctic Lands: a territory of France located in the southern Indian Ocean
|
"ATF",// French Southern and Antarctic Lands: a territory of France located in the southern Indian Ocean
|
||||||
|
"GIB",// Gibraltar: a British overseas territory located at the southern tip of the Iberian Peninsula
|
||||||
|
"GGY",// Guernsey: a British Crown dependency in the English Channel
|
||||||
"HMD",// Heard Island and McDonald Islands: an uninhabited Australian external territory in the southern Indian Ocean
|
"HMD",// Heard Island and McDonald Islands: an uninhabited Australian external territory in the southern Indian Ocean
|
||||||
|
"IMN",// Isle of Man: a British Crown dependency located in the Irish Sea
|
||||||
|
"JEY",// Jersey: a British Crown dependency located in the English Channel
|
||||||
"BLM",// Saint Barthélemy: an overseas collectivity of France in the Caribbean
|
"BLM",// Saint Barthélemy: an overseas collectivity of France in the Caribbean
|
||||||
"WSM",// Samoa: an independent island nation in the South Pacific
|
"WSM",// Samoa: an independent island nation in the South Pacific
|
||||||
"SXM",// Sint Maarten: a constituent country of the Kingdom of the Netherlands in the Caribbean
|
"SXM",// Sint Maarten: a constituent country of the Kingdom of the Netherlands in the Caribbean
|
||||||
"SGS",// South Georgia and the South Sandwich Islands: a British overseas territory in the southern Atlantic Ocean
|
"SGS",// South Georgia and the South Sandwich Islands: a British overseas territory in the southern Atlantic Ocean
|
||||||
|
"SJM",// Svalbard and Jan Mayen: an archipelago administered by Norway
|
||||||
"UMI",// United States Minor Outlying Islands: a collection of nine insular areas of the United States
|
"UMI",// United States Minor Outlying Islands: a collection of nine insular areas of the United States
|
||||||
"VIR",// United States Virgin Islands: an unincorporated territory of the United States in the Caribbean
|
"VIR",// United States Virgin Islands: an unincorporated territory of the United States in the Caribbean
|
||||||
]
|
]
|
||||||
|
|||||||
+43
-48
@@ -1,16 +1,17 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
LOCAL_SVG_PATH="app/src/main/assets/"
|
||||||
|
|
||||||
GADM_VERSION="4.1"
|
GADM_VERSION="4.1"
|
||||||
GADM_BASEPATH="https://geodata.ucdavis.edu/gadm"
|
GADM_BASEPATH="https://geodata.ucdavis.edu/gadm"
|
||||||
|
|
||||||
mapshaper="./node_modules/mapshaper/bin/mapshaper"
|
mapshaper="./node_modules/mapshaper/bin/mapshaper"
|
||||||
ATA_URL="https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/905b0baf5f4fb3b9ccf45293647dcacdb2b799d4/releaseData/gbOpen/ATA/ADM0/geoBoundaries-ATA-ADM0_simplified.geojson"
|
ATA_URL="https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/905b0baf5f4fb3b9ccf45293647dcacdb2b799d4/releaseData/gbOpen/ATA/ADM0/geoBoundaries-ATA-ADM0_simplified.geojson"
|
||||||
|
|
||||||
# Caspian Sea: "XCA"
|
|
||||||
countries=(
|
countries=(
|
||||||
"AFG" "XAD" "ALA" "ALB" "DZA" "ASM" "AND" "AGO" "AIA" "ATG" "ARG" "ARM" "ABW" "AUS" "AUT" "AZE"
|
"AFG" "XAD" "ALA" "ALB" "DZA" "ASM" "AND" "AGO" "AIA" "ATG" "ARG" "ARM" "ABW" "AUS" "AUT" "AZE"
|
||||||
"BHS" "BHR" "BGD" "BRB" "BLR" "BEL" "BLZ" "BEN" "BMU" "BTN" "BOL" "BES" "BIH" "BWA" "BVT" "BRA" "IOT" "VGB" "BRN" "BGR" "BFA" "BDI" "KHM"
|
"BHS" "BHR" "BGD" "BRB" "BLR" "BEL" "BLZ" "BEN" "BMU" "BTN" "BOL" "BES" "BIH" "BWA" "BVT" "BRA" "IOT" "VGB" "BRN" "BGR" "BFA" "BDI" "KHM"
|
||||||
"CMR" "CAN" "CPV" "CYM" "CAF" "TCD" "CHL" "CHN" "CXR" "XCL" "CCK" "COL" "COM" "COK" "CRI" "CIV" "HRV" "CUB" "CUW" "CYP" "CZE" "COD"
|
"CMR" "CAN" "CPV" "XCA" "CYM" "CAF" "TCD" "CHL" "CHN" "CXR" "XCL" "CCK" "COL" "COM" "COK" "CRI" "CIV" "HRV" "CUB" "CUW" "CYP" "CZE" "COD"
|
||||||
"DNK" "DJI" "DMA" "DOM" "ECU" "EGY" "SLV" "GNQ" "ERI" "EST" "ETH" "FLK" "FRO" "FJI" "FIN" "FRA" "GUF" "PYF" "ATF"
|
"DNK" "DJI" "DMA" "DOM" "ECU" "EGY" "SLV" "GNQ" "ERI" "EST" "ETH" "FLK" "FRO" "FJI" "FIN" "FRA" "GUF" "PYF" "ATF"
|
||||||
"GAB" "GMB" "GEO" "DEU" "GHA" "GIB" "GRC" "GRL" "GRD" "GLP" "GUM" "GTM" "GGY" "GIN" "GNB" "GUY" "HTI" "HMD" "HND" "HUN"
|
"GAB" "GMB" "GEO" "DEU" "GHA" "GIB" "GRC" "GRL" "GRD" "GLP" "GUM" "GTM" "GGY" "GIN" "GNB" "GUY" "HTI" "HMD" "HND" "HUN"
|
||||||
"ISL" "IND" "IDN" "IRN" "IRQ" "IRL" "IMN" "ISR" "ITA" "JAM" "JPN" "JEY" "JOR" "KAZ" "KEN" "KIR" "XKO" "KWT" "KGZ"
|
"ISL" "IND" "IDN" "IRN" "IRQ" "IRL" "IMN" "ISR" "ITA" "JAM" "JPN" "JEY" "JOR" "KAZ" "KEN" "KIR" "XKO" "KWT" "KGZ"
|
||||||
@@ -78,47 +79,44 @@ download_1() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
generate_map() {
|
toSVG_0() {
|
||||||
local OUT_FILE="$1" # First argument: Output destination path
|
local input_files=("ATA")
|
||||||
local PROJ_ARGS="$2" # Second argument: Projection parameters
|
|
||||||
shift 2 # Remove the first two arguments, leaving only the files
|
|
||||||
local FILES_TO_RUN=("$@") # Capture all remaining arguments as the file array
|
|
||||||
|
|
||||||
echo "Generating: $OUT_FILE using projection [$PROJ_ARGS]"
|
for country in "${countries[@]}"
|
||||||
echo "Processing ${#FILES_TO_RUN[@]} files..."
|
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
|
||||||
|
|
||||||
# Each feature is tagged with the place it belongs to and whether it is a
|
|
||||||
# country outline or one of its regions. packmap.js groups on that; nothing
|
|
||||||
# downstream needs the features split into layers.
|
|
||||||
local JS_PIPELINE_LOGIC="
|
|
||||||
const conflicts = {
|
|
||||||
'Z01': 'IND', 'Z04': 'IND', 'Z05': 'IND', 'Z07': 'IND', 'Z09': 'IND',
|
|
||||||
'Z02': 'CHN', 'Z03': 'CHN','Z08': 'CHN',
|
|
||||||
'Z06': 'PAK'
|
|
||||||
};
|
|
||||||
let rawCode = GID_0 || 'UNK';
|
|
||||||
let cCode = conflicts[rawCode] ? conflicts[rawCode] : rawCode;
|
|
||||||
// Antarctica's boundary file carries no country code at either level.
|
|
||||||
if (cCode === 'UNK') cCode = 'ATA';
|
|
||||||
let isGidMissing = (!GID || GID === 'undefined' || GID === 'null' || GID === '');
|
|
||||||
CODE = isGidMissing ? cCode : GID;
|
|
||||||
LEVEL = isGidMissing ? 0 : 1;
|
|
||||||
"
|
|
||||||
|
|
||||||
"$mapshaper" -i "${FILES_TO_RUN[@]}" combine-files \
|
|
||||||
-snap \
|
|
||||||
-merge-layers force\
|
|
||||||
-proj $PROJ_ARGS densify \
|
|
||||||
-simplify 10% weighted keep-shapes \
|
|
||||||
-filter-islands min-area=0 \
|
|
||||||
-sort 'this.area' ascending \
|
|
||||||
-each "$JS_PIPELINE_LOGIC" \
|
|
||||||
-o "$OUT_FILE" \
|
|
||||||
format=geojson
|
|
||||||
|
|
||||||
|
"$mapshaper" -i combine-files ${input_files[@]} -proj webmercator -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/mercator0.svg svg-data=GID_0,COUNTRY id-field=GID_0
|
||||||
|
"$mapshaper" -i combine-files ${input_files[@]} -proj aeqd +lat_0=90 -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/aeqd0.svg svg-data=GID_0,COUNTRY id-field=GID_0
|
||||||
}
|
}
|
||||||
|
|
||||||
build_maps() {
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
toSVG_01() {
|
||||||
input_files=()
|
input_files=()
|
||||||
|
|
||||||
input_files+=("./temp/1/ATA.json")
|
input_files+=("./temp/1/ATA.json")
|
||||||
@@ -138,12 +136,9 @@ build_maps() {
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
generate_map "./mapdata/loxim01.geojson" "loxim" "${input_files[@]}"
|
"$mapshaper" -i combine-files ${input_files[@]} snap -proj loxim densify -simplify 0.001 weighted keep-shapes -o ./app/src/main/assets/loxim01.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
|
||||||
generate_map "./mapdata/webmercator01.geojson" "webmercator" "${input_files[@]}"
|
"$mapshaper" -i combine-files ${input_files[@]} snap -proj webmercator densify -simplify 0.001 weighted keep-shapes -o ./app/src/main/assets/webmercator01.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
|
||||||
generate_map "./mapdata/aeqd01.geojson" "aeqd +lat_0=90" "${input_files[@]}"
|
"$mapshaper" -i combine-files ${input_files[@]} snap -proj aeqd +lat_0=90 densify -simplify 0.001 weighted keep-shapes -o ./app/src/main/assets/aeqd01.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
|
||||||
|
|
||||||
# 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() {
|
||||||
@@ -158,10 +153,10 @@ do_0() {
|
|||||||
do
|
do
|
||||||
download_0 "$country"
|
download_0 "$country"
|
||||||
done
|
done
|
||||||
wget -q -O "./temp/0/ATA.json" "$ATA_URL"
|
wget -q -O "./temp/1/ATA.json" "$ATA_URL"
|
||||||
}
|
}
|
||||||
# do_0
|
# do_0
|
||||||
# do_1
|
# do_1
|
||||||
build_maps
|
# toSVG_0
|
||||||
|
# toSVG_1
|
||||||
#CUW, CCK, XCL, CXR, IOT, BVT, ABW, FLK, GIB, HMD, KIR, SXM, MDV, MCO, NIU, NFK, PCN, MAF, SGS, VAT
|
toSVG_01
|
||||||
+19
-4
@@ -1,9 +1,24 @@
|
|||||||
# Project-wide Gradle settings.
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=false
|
android.enableJetifier=false
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
android.nonTransitiveRClass=true
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
android.uniquePackageNames=false
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
android.dependency.useConstraints=false
|
# thereby reducing the size of the R class for that library
|
||||||
android.r8.strictFullModeForKeepRules=false
|
android.nonTransitiveRClass=true
|
||||||
Vendored
BIN
Binary file not shown.
+2
-3
@@ -1,9 +1,8 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
retries=0
|
|
||||||
retryBackOffMs=500
|
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
#
|
#
|
||||||
# gradlew start up script for POSIX generated by Gradle.
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
#
|
#
|
||||||
# Important for running:
|
# Important for running:
|
||||||
#
|
#
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
# bash, then to run this script, type that shell name before the whole
|
# bash, then to run this script, type that shell name before the whole
|
||||||
# command line, like:
|
# command line, like:
|
||||||
#
|
#
|
||||||
# ksh gradlew
|
# ksh Gradle
|
||||||
#
|
#
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
# requires all of these POSIX shell features:
|
# requires all of these POSIX shell features:
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
# Darwin, MinGW, and NonStop.
|
# Darwin, MinGW, and NonStop.
|
||||||
#
|
#
|
||||||
# (3) This script is generated from the Groovy template
|
# (3) This script is generated from the Groovy template
|
||||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
# within the Gradle project.
|
# within the Gradle project.
|
||||||
#
|
#
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
|||||||
Vendored
+23
-12
@@ -19,12 +19,12 @@
|
|||||||
@if "%DEBUG%"=="" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem gradlew startup script for Windows
|
@rem Gradle startup script for Windows
|
||||||
@rem
|
@rem
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
|
|
||||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
@rem Set local scope for the variables with windows NT shell
|
||||||
setlocal EnableExtensions
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@@ -51,7 +51,7 @@ echo. 1>&2
|
|||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation. 1>&2
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
"%COMSPEC%" /c exit 1
|
goto fail
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
@@ -65,18 +65,29 @@ echo. 1>&2
|
|||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation. 1>&2
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
"%COMSPEC%" /c exit 1
|
goto fail
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute gradlew
|
@rem Execute Gradle
|
||||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
@rem which allows us to clear the local environment before executing the java command
|
|
||||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
|
||||||
|
|
||||||
:exitWithErrorLevel
|
:end
|
||||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
@rem End local scope for the variables with windows NT shell
|
||||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
|
|||||||
+325
@@ -0,0 +1,325 @@
|
|||||||
|
var continents = {
|
||||||
|
XAD:"EEE",
|
||||||
|
ALA:"EEE",
|
||||||
|
ALB:"EEE",
|
||||||
|
AND:"EEE",
|
||||||
|
AUT:"EEE",
|
||||||
|
BLR:"EEE",
|
||||||
|
BEL:"EEE",
|
||||||
|
BIH:"EEE",
|
||||||
|
BGR:"EEE",
|
||||||
|
HRV:"EEE",
|
||||||
|
CYP:"EEE",
|
||||||
|
CZE:"EEE",
|
||||||
|
DNK:"EEE",
|
||||||
|
EST:"EEE",
|
||||||
|
FRO:"EEE",
|
||||||
|
FIN:"EEE",
|
||||||
|
FRA:"EEE",
|
||||||
|
DEU:"EEE",
|
||||||
|
GIB:"EEE",
|
||||||
|
GRC:"EEE",
|
||||||
|
GGY:"EEE",
|
||||||
|
HUN:"EEE",
|
||||||
|
ISL:"EEE",
|
||||||
|
IRL:"EEE",
|
||||||
|
IMN:"EEE",
|
||||||
|
ITA:"EEE",
|
||||||
|
JEY:"EEE",
|
||||||
|
KAZ:"EEE",
|
||||||
|
XKO:"EEE",
|
||||||
|
LVA:"EEE",
|
||||||
|
LIE:"EEE",
|
||||||
|
LTU:"EEE",
|
||||||
|
LUX:"EEE",
|
||||||
|
MLT:"EEE",
|
||||||
|
MDA:"EEE",
|
||||||
|
MCO:"EEE",
|
||||||
|
MNE:"EEE",
|
||||||
|
NLD:"EEE",
|
||||||
|
MKD:"EEE",
|
||||||
|
NOR:"EEE",
|
||||||
|
POL:"EEE",
|
||||||
|
PRT:"EEE",
|
||||||
|
ROU:"EEE",
|
||||||
|
RUS:"EEE",
|
||||||
|
SMR:"EEE",
|
||||||
|
SRB:"EEE",
|
||||||
|
SVK:"EEE",
|
||||||
|
SVN:"EEE",
|
||||||
|
ESP:"EEE",
|
||||||
|
SJM:"EEE",
|
||||||
|
SWE:"EEE",
|
||||||
|
CHE:"EEE",
|
||||||
|
UKR:"EEE",
|
||||||
|
GBR:"EEE",
|
||||||
|
VAT:"EEE",
|
||||||
|
|
||||||
|
AFG:"ABB",
|
||||||
|
ARM:"ABB",
|
||||||
|
AZE:"ABB",
|
||||||
|
BHR:"ABB",
|
||||||
|
BGD:"ABB",
|
||||||
|
BTN:"ABB",
|
||||||
|
IOT:"ABB",
|
||||||
|
BRN:"ABB",
|
||||||
|
KHM:"ABB",
|
||||||
|
CCK:"ABB",
|
||||||
|
CHN:"ABB",
|
||||||
|
CXR:"ABB",
|
||||||
|
GEO:"ABB",
|
||||||
|
HKG:"ABB",
|
||||||
|
IND:"ABB",
|
||||||
|
IDN:"ABB",
|
||||||
|
IRN:"ABB",
|
||||||
|
IRQ:"ABB",
|
||||||
|
ISR:"ABB",
|
||||||
|
JPN:"ABB",
|
||||||
|
JOR:"ABB",
|
||||||
|
KWT:"ABB",
|
||||||
|
KGZ:"ABB",
|
||||||
|
LAO:"ABB",
|
||||||
|
LBN:"ABB",
|
||||||
|
MAC:"ABB",
|
||||||
|
MYS:"ABB",
|
||||||
|
MDV:"ABB",
|
||||||
|
MNG:"ABB",
|
||||||
|
MMR:"ABB",
|
||||||
|
NPL:"ABB",
|
||||||
|
PRK:"ABB",
|
||||||
|
OMN:"ABB",
|
||||||
|
PAK:"ABB",
|
||||||
|
PSE:"ABB",
|
||||||
|
PHL:"ABB",
|
||||||
|
QAT:"ABB",
|
||||||
|
SAU:"ABB",
|
||||||
|
SGP:"ABB",
|
||||||
|
KOR:"ABB",
|
||||||
|
LKA:"ABB",
|
||||||
|
SYR:"ABB",
|
||||||
|
TWN:"ABB",
|
||||||
|
TJK:"ABB",
|
||||||
|
THA:"ABB",
|
||||||
|
TLS:"ABB",
|
||||||
|
TUR:"ABB",
|
||||||
|
TKM:"ABB",
|
||||||
|
ARE:"ABB",
|
||||||
|
UZB:"ABB",
|
||||||
|
VNM:"ABB",
|
||||||
|
YEM:"ABB",
|
||||||
|
ZNC:"ABB",
|
||||||
|
|
||||||
|
DZA:"FFF",
|
||||||
|
AGO:"FFF",
|
||||||
|
BDI:"FFF",
|
||||||
|
BEN:"FFF",
|
||||||
|
BWA:"FFF",
|
||||||
|
BFA:"FFF",
|
||||||
|
BDI:"FFF",
|
||||||
|
CPV:"FFF",
|
||||||
|
CMR:"FFF",
|
||||||
|
CAF:"FFF",
|
||||||
|
TCD:"FFF",
|
||||||
|
COM:"FFF",
|
||||||
|
COG:"FFF",
|
||||||
|
COD:"FFF",
|
||||||
|
CIV:"FFF",
|
||||||
|
DJI:"FFF",
|
||||||
|
EGY:"FFF",
|
||||||
|
GNQ:"FFF",
|
||||||
|
ERI:"FFF",
|
||||||
|
ETH:"FFF",
|
||||||
|
ATF:"FFF",
|
||||||
|
GAB:"FFF",
|
||||||
|
GMB:"FFF",
|
||||||
|
GHA:"FFF",
|
||||||
|
GIN:"FFF",
|
||||||
|
GNB:"FFF",
|
||||||
|
KEN:"FFF",
|
||||||
|
LSO:"FFF",
|
||||||
|
LBR:"FFF",
|
||||||
|
LBY:"FFF",
|
||||||
|
MDG:"FFF",
|
||||||
|
MWI:"FFF",
|
||||||
|
MLI:"FFF",
|
||||||
|
MRT:"FFF",
|
||||||
|
MUS:"FFF",
|
||||||
|
MYT:"FFF",
|
||||||
|
MAR:"FFF",
|
||||||
|
MOZ:"FFF",
|
||||||
|
NAM:"FFF",
|
||||||
|
NER:"FFF",
|
||||||
|
NGA:"FFF",
|
||||||
|
COD:"FFF",
|
||||||
|
REU:"FFF",
|
||||||
|
RWA:"FFF",
|
||||||
|
STP:"FFF",
|
||||||
|
SEN:"FFF",
|
||||||
|
SYC:"FFF",
|
||||||
|
SLE:"FFF",
|
||||||
|
SOM:"FFF",
|
||||||
|
ZAF:"FFF",
|
||||||
|
SSD:"FFF",
|
||||||
|
SHN:"FFF",
|
||||||
|
SDN:"FFF",
|
||||||
|
SWZ:"FFF",
|
||||||
|
TZA:"FFF",
|
||||||
|
TGO:"FFF",
|
||||||
|
TUN:"FFF",
|
||||||
|
UGA:"FFF",
|
||||||
|
COD:"FFF",
|
||||||
|
ZMB:"FFF",
|
||||||
|
ZWE:"FFF",
|
||||||
|
ESH:"FFF",
|
||||||
|
|
||||||
|
ABW:"NNN",
|
||||||
|
AIA:"NNN",
|
||||||
|
ATG:"NNN",
|
||||||
|
BHS:"NNN",
|
||||||
|
BRB:"NNN",
|
||||||
|
BLZ:"NNN",
|
||||||
|
BMU:"NNN",
|
||||||
|
BES:"NNN",
|
||||||
|
VGB:"NNN",
|
||||||
|
CAN:"NNN",
|
||||||
|
CYM:"NNN",
|
||||||
|
XCL:"NNN",
|
||||||
|
CRI:"NNN",
|
||||||
|
CUB:"NNN",
|
||||||
|
CUW:"NNN",
|
||||||
|
DMA:"NNN",
|
||||||
|
DOM:"NNN",
|
||||||
|
SLV:"NNN",
|
||||||
|
GRL:"NNN",
|
||||||
|
GRD:"NNN",
|
||||||
|
GLP:"NNN",
|
||||||
|
GTM:"NNN",
|
||||||
|
HTI:"NNN",
|
||||||
|
HND:"NNN",
|
||||||
|
JAM:"NNN",
|
||||||
|
MTQ:"NNN",
|
||||||
|
MEX:"NNN",
|
||||||
|
MSR:"NNN",
|
||||||
|
ANT:"NNN",
|
||||||
|
CUW:"NNN",
|
||||||
|
NIC:"NNN",
|
||||||
|
PAN:"NNN",
|
||||||
|
PRI:"NNN",
|
||||||
|
BLM:"NNN",
|
||||||
|
KNA:"NNN",
|
||||||
|
LCA:"NNN",
|
||||||
|
MAF:"NNN",
|
||||||
|
SPM:"NNN",
|
||||||
|
VCT:"NNN",
|
||||||
|
SXM:"NNN",
|
||||||
|
TTO:"NNN",
|
||||||
|
TCA:"NNN",
|
||||||
|
USA:"NNN",
|
||||||
|
UMI:"NNN",
|
||||||
|
VIR:"NNN",
|
||||||
|
|
||||||
|
ARG:"SRR",
|
||||||
|
BOL:"SRR",
|
||||||
|
BRA:"SRR",
|
||||||
|
CHL:"SRR",
|
||||||
|
COL:"SRR",
|
||||||
|
ECU:"SRR",
|
||||||
|
FLK:"SRR",
|
||||||
|
GUF:"SRR",
|
||||||
|
GUY:"SRR",
|
||||||
|
PRY:"SRR",
|
||||||
|
PER:"SRR",
|
||||||
|
SUR:"SRR",
|
||||||
|
URY:"SRR",
|
||||||
|
VEN:"SRR",
|
||||||
|
|
||||||
|
ASM:"UUU",
|
||||||
|
AUS:"UUU",
|
||||||
|
COK:"UUU",
|
||||||
|
FJI:"UUU",
|
||||||
|
PYF:"UUU",
|
||||||
|
GUM:"UUU",
|
||||||
|
KIR:"UUU",
|
||||||
|
MHL:"UUU",
|
||||||
|
FSM:"UUU",
|
||||||
|
NRU:"UUU",
|
||||||
|
NCL:"UUU",
|
||||||
|
NZL:"UUU",
|
||||||
|
NIU:"UUU",
|
||||||
|
NFK:"UUU",
|
||||||
|
MNP:"UUU",
|
||||||
|
PLW:"UUU",
|
||||||
|
PNG:"UUU",
|
||||||
|
PCN:"UUU",
|
||||||
|
WSM:"UUU",
|
||||||
|
SLB:"UUU",
|
||||||
|
TKL:"UUU",
|
||||||
|
TON:"UUU",
|
||||||
|
TUV:"UUU",
|
||||||
|
VUT:"UUU",
|
||||||
|
WLF:"UUU",
|
||||||
|
|
||||||
|
ATA:"XXX",
|
||||||
|
BVT:"XXX",
|
||||||
|
HMD:"XXX",
|
||||||
|
SGS:"XXX",
|
||||||
|
}
|
||||||
|
|
||||||
|
import fs from "fs"
|
||||||
|
import {JSDOM} from "jsdom"
|
||||||
|
|
||||||
|
function groupByContinent(svgFilePath) {
|
||||||
|
// Read the SVG file
|
||||||
|
fs.readFile(svgFilePath, 'utf-8', (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error reading SVG file:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a virtual DOM with JSDOM
|
||||||
|
const dom = new JSDOM(data);
|
||||||
|
const document = dom.window.document;
|
||||||
|
|
||||||
|
// Select the root <svg> element
|
||||||
|
const svgElement = document.querySelector('svg');
|
||||||
|
|
||||||
|
// Group <g> elements representing countries by continent
|
||||||
|
const world = {}; // Renamed continents object to 'world'
|
||||||
|
svgElement.querySelectorAll('g').forEach(countryGroup => {
|
||||||
|
const countryId = countryGroup.getAttribute('id');
|
||||||
|
if (countryId!= null){
|
||||||
|
const continentId = continents[countryId.replace(/\d/g, "")] || "XXX"; // Remove numbers from country ID to get continent ID
|
||||||
|
if (!world[continentId]) {
|
||||||
|
world[continentId] = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||||
|
world[continentId].setAttribute('id', continentId);
|
||||||
|
}
|
||||||
|
world[continentId].innerHTML += countryGroup.outerHTML;
|
||||||
|
}else{
|
||||||
|
|
||||||
|
console.log(countryId,countryGroup.outerHTML)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new <g> elements for each continent
|
||||||
|
svgElement.innerHTML = '';
|
||||||
|
for (const continentId in world) {
|
||||||
|
svgElement.appendChild(world[continentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output the modified SVG
|
||||||
|
fs.writeFile(svgFilePath, svgElement.outerHTML, 'utf-8', err => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error writing to SVG file:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('SVG file updated successfully.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Example usage:
|
||||||
|
const svgFileBasePath = "./app/src/main/assets/" ;
|
||||||
|
groupByContinent(svgFileBasePath+ "webmercator01.svg");
|
||||||
|
groupByContinent(svgFileBasePath+ "aeqd01.svg");
|
||||||
|
groupByContinent(svgFileBasePath+ "loxim01.svg");
|
||||||
+4
-6
@@ -1,11 +1,9 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
|
||||||
"pack": "node packmap.js mapdata/loxim01.geojson mapdata/webmercator01.geojson mapdata/aeqd01.geojson"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@turf/area": "^7.3.5",
|
"@turf/area": "^7.0.0",
|
||||||
"@turf/turf": "^7.3.5",
|
"@turf/turf": "^7.0.0",
|
||||||
"mapshaper": "^0.7.22"
|
"jsdom": "^28.0.0",
|
||||||
|
"mapshaper": "^0.6.79"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|||||||
-232
@@ -1,232 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -6,9 +6,6 @@ pluginManagement {
|
|||||||
maven { url 'https://jitpack.io' }
|
maven { url 'https://jitpack.io' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
plugins {
|
|
||||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
|
||||||
}
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
Reference in New Issue
Block a user