Custom Map Renderer

This commit is contained in:
2026-09-06 19:53:57 +02:00
parent 1e6a374e96
commit fe766c8820
15 changed files with 978 additions and 181 deletions
-3
View File
@@ -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
}
}
+6
View File
@@ -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>