Custom Map Renderer
This commit is contained in:
@@ -102,9 +102,6 @@ dependencies {
|
|||||||
implementation 'com.google.android.material:material:1.14.0'
|
implementation 'com.google.android.material:material:1.14.0'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.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:14.2.1'
|
||||||
implementation 'com.mikepenz:aboutlibraries-compose-m3:15.2.0'
|
implementation 'com.mikepenz:aboutlibraries-compose-m3:15.2.0'
|
||||||
implementation 'com.mikepenz:aboutlibraries-core:15.2.0'
|
implementation 'com.mikepenz:aboutlibraries-core:15.2.0'
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package net.helcel.beans.activity
|
package net.helcel.beans.activity
|
||||||
|
|
||||||
import android.graphics.drawable.PictureDrawable
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.ImageView
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
import androidx.compose.material.IconButton
|
import androidx.compose.material.IconButton
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
@@ -21,30 +22,42 @@ import androidx.compose.material.icons.filled.Edit
|
|||||||
import androidx.compose.material.icons.filled.Percent
|
import androidx.compose.material.icons.filled.Percent
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.caverock.androidsvg.RenderOptions
|
|
||||||
import com.github.chrisbanes.photoview.PhotoView
|
|
||||||
import net.helcel.beans.BuildConfig
|
import net.helcel.beans.BuildConfig
|
||||||
|
import net.helcel.beans.activity.sub.EditPlaceDialog
|
||||||
|
import net.helcel.beans.activity.sub.MapPickDialog
|
||||||
|
import net.helcel.beans.activity.sub.applyDirectVisit
|
||||||
|
import net.helcel.beans.activity.sub.commitVisitDialog
|
||||||
|
import net.helcel.beans.countries.GeoLoc
|
||||||
import net.helcel.beans.countries.GeoLocImporter
|
import net.helcel.beans.countries.GeoLocImporter
|
||||||
|
import net.helcel.beans.countries.GeoLocTree
|
||||||
import net.helcel.beans.helper.Data
|
import net.helcel.beans.helper.Data
|
||||||
import net.helcel.beans.helper.Settings
|
import net.helcel.beans.helper.Settings
|
||||||
import net.helcel.beans.svg.CSSWrapper
|
import net.helcel.beans.map.MapAssets
|
||||||
import net.helcel.beans.svg.SVGWrapper
|
import net.helcel.beans.map.MapStyle
|
||||||
|
import net.helcel.beans.map.MapView
|
||||||
|
import net.helcel.beans.map.MapWorld
|
||||||
|
import net.helcel.beans.map.MapReader
|
||||||
|
|
||||||
|
|
||||||
class MainScreen : ComponentActivity() {
|
class MainScreen : ComponentActivity() {
|
||||||
|
|
||||||
private var psvg by mutableStateOf<SVGWrapper?>(null)
|
private var world by mutableStateOf<MapWorld?>(null)
|
||||||
private var css by mutableStateOf<CSSWrapper?>(null)
|
private var loadToken = 0
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -57,7 +70,15 @@ class MainScreen : ComponentActivity() {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SysTheme {
|
SysTheme {
|
||||||
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.primary).statusBarsPadding(),) {
|
// Both bars: without the navigation inset the system buttons sit on top of
|
||||||
|
// whatever is at the bottom of a screen, such as the About button.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colors.primary)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
) {
|
||||||
AppNavHost()
|
AppNavHost()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,13 +89,7 @@ class MainScreen : ComponentActivity() {
|
|||||||
fun AppNavHost() {
|
fun AppNavHost() {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
NavHost(navController, startDestination = "main") {
|
NavHost(navController, startDestination = "main") {
|
||||||
composable("main") {
|
composable("main") { MainScreenC(world, navController) }
|
||||||
val currentPsvg = psvg
|
|
||||||
val currentCss = css
|
|
||||||
if (currentPsvg != null && currentCss != null) {
|
|
||||||
MainScreenC(currentPsvg, currentCss, navController)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable("settings") { SettingsMainScreen { navController.navigate("main") } }
|
composable("settings") { SettingsMainScreen { navController.navigate("main") } }
|
||||||
composable("edit") { EditScreen { navController.navigate("main") } }
|
composable("edit") { EditScreen { navController.navigate("main") } }
|
||||||
composable("stats") { StatsScreen { navController.navigate("main") } }
|
composable("stats") { StatsScreen { navController.navigate("main") } }
|
||||||
@@ -82,7 +97,7 @@ class MainScreen : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreenC(psvg: SVGWrapper,css: CSSWrapper, nav: NavHostController){
|
fun MainScreenC(world: MapWorld?, nav: NavHostController){
|
||||||
SysTheme {
|
SysTheme {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -102,40 +117,87 @@ class MainScreen : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Box(modifier = Modifier.padding(innerPadding)) {
|
Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
|
||||||
MapScreen(psvg, css)
|
if (world == null) LoadingMap() else MapScreen(world)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MapScreen(psvg: SVGWrapper, css: CSSWrapper) {
|
fun LoadingMap() {
|
||||||
Box {
|
Box(
|
||||||
val cssContent = css.get()
|
modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background),
|
||||||
val drawable = remember(psvg, css, cssContent) {
|
contentAlignment = Alignment.Center,
|
||||||
val opt: RenderOptions = RenderOptions.create()
|
) {
|
||||||
opt.css(cssContent)
|
CircularProgressIndicator(
|
||||||
PictureDrawable(psvg.get()?.renderToPicture(opt))
|
color = MaterialTheme.colors.primary,
|
||||||
}
|
strokeWidth = 4.dp,
|
||||||
AndroidView(
|
modifier = Modifier.size(50.dp),
|
||||||
factory = { ctx ->
|
|
||||||
PhotoView(ctx).apply {
|
|
||||||
setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null)
|
|
||||||
maximumScale = 64f
|
|
||||||
scaleType = ImageView.ScaleType.FIT_CENTER
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update = { view ->
|
|
||||||
view.setImageDrawable(drawable)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MapScreen(world: MapWorld) {
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
val visits by Data.visits.visitsFlow.collectAsState()
|
||||||
|
val groups by Data.groups.groupsFlow.collectAsState()
|
||||||
|
val land = MaterialTheme.colors.onBackground.toArgb()
|
||||||
|
val background = MaterialTheme.colors.background.toArgb()
|
||||||
|
val style = remember(visits, groups, land, background) {
|
||||||
|
MapStyle.build(ctx, land, background)
|
||||||
|
}
|
||||||
|
val touchRadius = Settings.getTouchRadius(ctx)
|
||||||
|
|
||||||
|
var candidates by remember { mutableStateOf<List<GeoLoc>>(emptyList()) }
|
||||||
|
var showColor by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// A place is either applied straight away, or the colour dialog picks
|
||||||
|
// the group for it, exactly as it does from the edit list.
|
||||||
|
fun select(loc: GeoLoc) {
|
||||||
|
if (!applyDirectVisit(ctx, loc)) showColor = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.isNotEmpty()) {
|
||||||
|
MapPickDialog(
|
||||||
|
candidates = candidates,
|
||||||
|
onPick = { candidates = emptyList(); select(it) },
|
||||||
|
onDismiss = { candidates = emptyList() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showColor) {
|
||||||
|
EditPlaceDialog(false) { cleared ->
|
||||||
|
showColor = false
|
||||||
|
commitVisitDialog(cleared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
factory = { MapView(it) },
|
||||||
|
update = { view ->
|
||||||
|
view.world = world
|
||||||
|
view.style = style
|
||||||
|
view.touchRadiusDp = touchRadius
|
||||||
|
// Always the tree, even for a single hit: landing on a region is
|
||||||
|
// just as often a way of reaching the country around it.
|
||||||
|
view.onPick = { picks ->
|
||||||
|
val locs = picks.mapNotNull { GeoLocTree.find(it.code) }
|
||||||
|
if (locs.isNotEmpty()) candidates = locs
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reloads the map asset, on a worker thread since it is a few megabytes. */
|
||||||
fun refreshProjection() {
|
fun refreshProjection() {
|
||||||
psvg = SVGWrapper(this)
|
val asset = MapAssets.assetFor(this)
|
||||||
css = CSSWrapper(this)
|
val token = ++loadToken
|
||||||
|
world = null
|
||||||
|
Thread {
|
||||||
|
val parsed = assets.open(asset).use { MapReader.read(it) }
|
||||||
|
runOnUiThread { if (token == loadToken) world = parsed }
|
||||||
|
}.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package net.helcel.beans.activity.sub
|
package net.helcel.beans.activity.sub
|
||||||
|
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -54,6 +55,44 @@ fun EditPlaceScreenPreview(){
|
|||||||
EditPlaceScreen(Group.EEE)
|
EditPlaceScreen(Group.EEE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a place straight away where a single group makes the choice obvious,
|
||||||
|
* as tapping the checkbox does. Returns false when the group still has to be
|
||||||
|
* picked from the colour dialog.
|
||||||
|
*/
|
||||||
|
fun applyDirectVisit(ctx: Context, loc: GeoLoc): Boolean {
|
||||||
|
Data.selected_geoloc = loc
|
||||||
|
Data.selected_group = null
|
||||||
|
if (Data.groups.size() != 1 || !Settings.isSingleGroup(ctx)) return false
|
||||||
|
val current = Data.visits.getVisited(loc)
|
||||||
|
Data.visits.setVisited(
|
||||||
|
loc,
|
||||||
|
if (current == NO_GROUP || current == AUTO_GROUP) Data.groups.getUniqueEntry()!!.key
|
||||||
|
else if (loc.children.any { Data.visits.getVisited(it) != NO_GROUP }) AUTO_GROUP
|
||||||
|
else NO_GROUP,
|
||||||
|
)
|
||||||
|
syncVisited()
|
||||||
|
Data.saveData()
|
||||||
|
Data.selected_geoloc = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores whatever the colour dialog came back with. */
|
||||||
|
fun commitVisitDialog(cleared: Boolean) {
|
||||||
|
if (cleared) {
|
||||||
|
Data.visits.setVisited(Data.selected_geoloc, NO_GROUP)
|
||||||
|
syncVisited()
|
||||||
|
Data.saveData()
|
||||||
|
}
|
||||||
|
if (Data.selected_group != null && Data.selected_geoloc != null) {
|
||||||
|
Data.visits.setVisited(Data.selected_geoloc, Data.selected_group!!.key)
|
||||||
|
syncVisited()
|
||||||
|
Data.saveData()
|
||||||
|
}
|
||||||
|
Data.selected_geoloc = null
|
||||||
|
Data.selected_group = null
|
||||||
|
}
|
||||||
|
|
||||||
fun syncVisited(loc: GeoLoc?=World.WWW): Boolean {
|
fun syncVisited(loc: GeoLoc?=World.WWW): Boolean {
|
||||||
var changed = false
|
var changed = false
|
||||||
loc?.children?.forEach { tt ->
|
loc?.children?.forEach { tt ->
|
||||||
@@ -122,22 +161,7 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
|||||||
if(showEdit)
|
if(showEdit)
|
||||||
EditPlaceDialog(false) {
|
EditPlaceDialog(false) {
|
||||||
showEdit = false
|
showEdit = false
|
||||||
if (it) {
|
commitVisitDialog(it)
|
||||||
Data.visits.setVisited(Data.selected_geoloc, NO_GROUP)
|
|
||||||
syncVisited()
|
|
||||||
Data.saveData()
|
|
||||||
|
|
||||||
if (Data.selected_geoloc!=null && Data.selected_geoloc!!.children.any { itc-> Data.visits.getVisited(itc) != NO_GROUP }) {
|
|
||||||
Data.clearing_geoloc = Data.selected_geoloc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Data.selected_group != null && Data.selected_geoloc != null) {
|
|
||||||
Data.visits.setVisited(Data.selected_geoloc, Data.selected_group!!.key)
|
|
||||||
syncVisited()
|
|
||||||
Data.saveData()
|
|
||||||
}
|
|
||||||
Data.selected_geoloc = null
|
|
||||||
Data.selected_group = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
@@ -167,22 +191,7 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
|||||||
tabs.add(loc)
|
tabs.add(loc)
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
Data.selected_geoloc = loc
|
if (!applyDirectVisit(ctx, loc)) showEdit = true
|
||||||
if (Data.groups.size() == 1 && Settings.isSingleGroup(ctx)) {
|
|
||||||
Data.visits.setVisited(Data.selected_geoloc,
|
|
||||||
if (it != ToggleableState.On) Data.groups.getUniqueEntry()!!.key
|
|
||||||
else if(Data.selected_geoloc?.children?.any{ itc->
|
|
||||||
Data.visits.getVisited(itc)!= NO_GROUP } == true) AUTO_GROUP
|
|
||||||
else NO_GROUP
|
|
||||||
)
|
|
||||||
Data.saveData()
|
|
||||||
Data.selected_group = null
|
|
||||||
} else {
|
|
||||||
Data.selected_group = null
|
|
||||||
showEdit=true
|
|
||||||
}
|
|
||||||
syncVisited()
|
|
||||||
Data.saveData()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -196,7 +205,7 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
|
|||||||
fun GeoLocRow(
|
fun GeoLocRow(
|
||||||
loc: GeoLoc,
|
loc: GeoLoc,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onCheckedChange: (ToggleableState) -> Unit
|
onToggle: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val visits by Data.visits.visitsFlow.collectAsState()
|
val visits by Data.visits.visitsFlow.collectAsState()
|
||||||
val checked by remember(visits, loc) {
|
val checked by remember(visits, loc) {
|
||||||
@@ -235,7 +244,7 @@ fun GeoLocRow(
|
|||||||
|
|
||||||
TriStateCheckbox(
|
TriStateCheckbox(
|
||||||
state = checked,
|
state = checked,
|
||||||
onClick= { onCheckedChange(checked) },
|
onClick = onToggle,
|
||||||
colors = CheckboxDefaults.colors(
|
colors = CheckboxDefaults.colors(
|
||||||
checkedColor = color,
|
checkedColor = color,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package net.helcel.beans.activity.sub
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import net.helcel.beans.R
|
||||||
|
import net.helcel.beans.activity.SysTheme
|
||||||
|
import net.helcel.beans.countries.GeoLoc
|
||||||
|
import net.helcel.beans.countries.GeoLocTree
|
||||||
|
import net.helcel.beans.helper.AUTO_GROUP
|
||||||
|
import net.helcel.beans.helper.Data
|
||||||
|
import net.helcel.beans.helper.NO_GROUP
|
||||||
|
|
||||||
|
private class PickRow(val loc: GeoLoc, val depth: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the shapes a tap landed near into an indented list.
|
||||||
|
*
|
||||||
|
* Each candidate brings its continent and country along, so a tap that is
|
||||||
|
* ambiguous between two regions can still be answered with "the whole country"
|
||||||
|
* or "the whole continent" instead of only the regions themselves.
|
||||||
|
*/
|
||||||
|
private fun rowsFor(candidates: List<GeoLoc>): List<PickRow> {
|
||||||
|
val rows = LinkedHashMap<String, PickRow>()
|
||||||
|
candidates.forEach { candidate ->
|
||||||
|
GeoLocTree.chain(candidate).forEachIndexed { depth, loc ->
|
||||||
|
rows.getOrPut(loc.code) { PickRow(loc, depth) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.values.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MapPickDialog(
|
||||||
|
candidates: List<GeoLoc>,
|
||||||
|
onPick: (GeoLoc) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val visits by Data.visits.visitsFlow.collectAsState()
|
||||||
|
val rows = remember(candidates) { rowsFor(candidates) }
|
||||||
|
|
||||||
|
SysTheme {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colors.background,
|
||||||
|
RoundedCornerShape(corner = CornerSize(16.dp)),
|
||||||
|
)
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
style = MaterialTheme.typography.h6,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
text = stringResource(R.string.select_place),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
style = MaterialTheme.typography.caption,
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
text = stringResource(R.string.select_place_sub),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 360.dp),
|
||||||
|
) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
items(rows, key = { it.loc.code }) { row ->
|
||||||
|
val group = visits.getOrElse(row.loc.code) { NO_GROUP }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onPick(row.loc) }
|
||||||
|
.background(
|
||||||
|
Color(88, 88, 88, 88),
|
||||||
|
RoundedCornerShape(corner = CornerSize(16.dp)),
|
||||||
|
)
|
||||||
|
.padding(
|
||||||
|
start = 8.dp + 16.dp * row.depth,
|
||||||
|
top = 8.dp,
|
||||||
|
end = 8.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.background(
|
||||||
|
if (group == NO_GROUP || group == AUTO_GROUP) {
|
||||||
|
MaterialTheme.colors.onBackground
|
||||||
|
} else {
|
||||||
|
Color(
|
||||||
|
Data.groups.getGroupFromKey(group).color.color
|
||||||
|
)
|
||||||
|
},
|
||||||
|
CircleShape,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
style = MaterialTheme.typography.body2,
|
||||||
|
text = row.loc.fullName,
|
||||||
|
// Long names wrap inside the row rather
|
||||||
|
// than running off the end of it.
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ object GeoLocImporter {
|
|||||||
|
|
||||||
fun importStates(ctx: Context, force: Boolean = false) {
|
fun importStates(ctx: Context, force: Boolean = false) {
|
||||||
if (!Settings.isRegional(ctx) and !force) {
|
if (!Settings.isRegional(ctx) and !force) {
|
||||||
|
GeoLocTree.rebuild()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val fs = BufferedReader(InputStreamReader(ctx.assets.open("geoloc_state.txt")))
|
val fs = BufferedReader(InputStreamReader(ctx.assets.open("geoloc_state.txt")))
|
||||||
@@ -26,6 +27,7 @@ object GeoLocImporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
GeoLocTree.rebuild()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearStates() {
|
fun clearStates() {
|
||||||
@@ -39,6 +41,7 @@ object GeoLocImporter {
|
|||||||
}
|
}
|
||||||
country.children.clear()
|
country.children.clear()
|
||||||
}
|
}
|
||||||
|
GeoLocTree.rebuild()
|
||||||
Data.saveData()
|
Data.saveData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package net.helcel.beans.countries
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code to place lookup, plus the chain of parents above a place.
|
||||||
|
*
|
||||||
|
* Regions only exist once [GeoLocImporter] has read them, so this is rebuilt
|
||||||
|
* whenever that set changes rather than derived once and cached forever.
|
||||||
|
*/
|
||||||
|
object GeoLocTree {
|
||||||
|
|
||||||
|
private var index: Map<String, GeoLoc> = emptyMap()
|
||||||
|
private var parents: Map<String, GeoLoc> = emptyMap()
|
||||||
|
|
||||||
|
fun rebuild() {
|
||||||
|
val byCode = HashMap<String, GeoLoc>()
|
||||||
|
val parent = HashMap<String, GeoLoc>()
|
||||||
|
byCode[World.WWW.code] = World.WWW
|
||||||
|
World.WWW.children.forEach { child ->
|
||||||
|
byCode[child.code] = child
|
||||||
|
parent[child.code] = World.WWW
|
||||||
|
child.children.forEach { country ->
|
||||||
|
byCode[country.code] = country
|
||||||
|
parent[country.code] = child
|
||||||
|
country.children.forEach { state ->
|
||||||
|
byCode[state.code] = state
|
||||||
|
parent[state.code] = country
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index = byCode
|
||||||
|
parents = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
fun find(code: String): GeoLoc? {
|
||||||
|
if (index.isEmpty()) rebuild()
|
||||||
|
return index[code]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [loc] and everything above it, world first, so it reads as a path. */
|
||||||
|
fun chain(loc: GeoLoc): List<GeoLoc> {
|
||||||
|
if (index.isEmpty()) rebuild()
|
||||||
|
val path = ArrayList<GeoLoc>(4)
|
||||||
|
var current: GeoLoc? = loc
|
||||||
|
while (current != null && current != World.WWW) {
|
||||||
|
path.add(current)
|
||||||
|
current = parents[current.code]
|
||||||
|
}
|
||||||
|
return path.reversed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -6,6 +6,16 @@ import androidx.preference.PreferenceManager
|
|||||||
import net.helcel.beans.R
|
import net.helcel.beans.R
|
||||||
import net.helcel.beans.activity.MainScreen
|
import net.helcel.beans.activity.MainScreen
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default distance, in dp, around a tap that still counts as hitting a place.
|
||||||
|
* Kept small because the radius is measured on screen: zoomed out to the whole
|
||||||
|
* world, even a few dp reach across several countries.
|
||||||
|
*/
|
||||||
|
const val DEFAULT_TOUCH_RADIUS = 4
|
||||||
|
|
||||||
|
/** Largest tap radius the setting offers. */
|
||||||
|
const val MAX_TOUCH_RADIUS = 48
|
||||||
|
|
||||||
object Settings {
|
object Settings {
|
||||||
|
|
||||||
private lateinit var sp: SharedPreferences
|
private lateinit var sp: SharedPreferences
|
||||||
@@ -29,6 +39,14 @@ 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 {
|
fun isCascadeStats(ctx: Context): Boolean {
|
||||||
return getBooleanValue(
|
return getBooleanValue(
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,10 @@
|
|||||||
<string name="pref_cascade_stats">Cascade statistics</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="pref_cascade_stats_desc">Include parents and children in the counts</string>
|
||||||
<string name="key_group_desc">Enable multiple colors</string>
|
<string name="key_group_desc">Enable multiple colors</string>
|
||||||
|
<string name="key_touch_radius">key_touch_radius</string>
|
||||||
|
<string name="pref_touch_radius">Tap radius</string>
|
||||||
|
<string name="pref_touch_radius_desc">How far around a tap on the map other places should be considered</string>
|
||||||
|
<string name="pref_touch_radius_value">%1$d dp</string>
|
||||||
<string name="about">About</string>
|
<string name="about">About</string>
|
||||||
<string name="pref_category_appearance">Appearance</string>
|
<string name="pref_category_appearance">Appearance</string>
|
||||||
<string name="pref_category_features">Features</string>
|
<string name="pref_category_features">Features</string>
|
||||||
@@ -40,6 +44,8 @@
|
|||||||
<string name="select_group">Select the group to keep.</string>
|
<string name="select_group">Select the group to keep.</string>
|
||||||
<string name="select_group_sub">All others will be deleted and its mappings reassigned to the group you choose here.</string>
|
<string name="select_group_sub">All others will be deleted and its mappings reassigned to the group you choose here.</string>
|
||||||
<string name="delete_group">Are your sure you want to delete this group and remove all its country mappings?</string>
|
<string name="delete_group">Are your sure you want to delete this group and remove all its country mappings?</string>
|
||||||
|
<string name="select_place">Select a place</string>
|
||||||
|
<string name="select_place_sub">Tap the place to color, or a larger one that contains it.</string>
|
||||||
<string name="delete_regions">Are you sure you want to disable regions and reassign all regional mappings to the corresponding countries?</string>
|
<string name="delete_regions">Are you sure you want to disable regions and reassign all regional mappings to the corresponding countries?</string>
|
||||||
|
|
||||||
<string name="name">Name</string>
|
<string name="name">Name</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user