Custom Map Renderer
This commit is contained in:
@@ -102,9 +102,6 @@ dependencies {
|
||||
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.2.0'
|
||||
implementation 'com.mikepenz:aboutlibraries-core:15.2.0'
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
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
|
||||
@@ -21,30 +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.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 var psvg by mutableStateOf<SVGWrapper?>(null)
|
||||
private var css by mutableStateOf<CSSWrapper?>(null)
|
||||
private var world by mutableStateOf<MapWorld?>(null)
|
||||
private var loadToken = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -57,7 +70,15 @@ class MainScreen : ComponentActivity() {
|
||||
|
||||
setContent {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -68,13 +89,7 @@ class MainScreen : ComponentActivity() {
|
||||
fun AppNavHost() {
|
||||
val navController = rememberNavController()
|
||||
NavHost(navController, startDestination = "main") {
|
||||
composable("main") {
|
||||
val currentPsvg = psvg
|
||||
val currentCss = css
|
||||
if (currentPsvg != null && currentCss != null) {
|
||||
MainScreenC(currentPsvg, currentCss, navController)
|
||||
}
|
||||
}
|
||||
composable("main") { MainScreenC(world, navController) }
|
||||
composable("settings") { SettingsMainScreen { navController.navigate("main") } }
|
||||
composable("edit") { EditScreen { navController.navigate("main") } }
|
||||
composable("stats") { StatsScreen { navController.navigate("main") } }
|
||||
@@ -82,7 +97,7 @@ class MainScreen : ComponentActivity() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreenC(psvg: SVGWrapper,css: CSSWrapper, nav: NavHostController){
|
||||
fun MainScreenC(world: MapWorld?, nav: NavHostController){
|
||||
SysTheme {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -102,40 +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 cssContent = css.get()
|
||||
val drawable = remember(psvg, css, cssContent) {
|
||||
val opt: RenderOptions = RenderOptions.create()
|
||||
opt.css(cssContent)
|
||||
PictureDrawable(psvg.get()?.renderToPicture(opt))
|
||||
}
|
||||
AndroidView(
|
||||
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()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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.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
|
||||
@@ -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 {
|
||||
return getBooleanValue(
|
||||
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_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>
|
||||
@@ -40,6 +44,8 @@
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user