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