From fe766c882003ebf4aa05c077b70686044a8e1ac8 Mon Sep 17 00:00:00 2001 From: soraefir Date: Sun, 6 Sep 2026 19:53:57 +0200 Subject: [PATCH] Custom Map Renderer --- app/build.gradle | 3 - .../net/helcel/beans/activity/MainScreen.kt | 144 +++++--- .../beans/activity/sub/EditPlaceScreen.kt | 77 ++-- .../beans/activity/sub/MapPickDialog.kt | 157 ++++++++ .../helcel/beans/countries/GeoLocImporter.kt | 3 + .../net/helcel/beans/countries/GeoLocTree.kt | 50 +++ .../java/net/helcel/beans/helper/Prefs.kt | 12 + .../java/net/helcel/beans/helper/Settings.kt | 18 + .../java/net/helcel/beans/map/MapAssets.kt | 23 ++ .../java/net/helcel/beans/map/MapRenderer.kt | 103 ++++++ .../java/net/helcel/beans/map/MapStyle.kt | 115 ++++++ .../main/java/net/helcel/beans/map/MapView.kt | 345 ++++++++++++++++++ .../java/net/helcel/beans/svg/CSSWrapper.kt | 75 ---- .../java/net/helcel/beans/svg/SVGWrapper.kt | 28 -- app/src/main/res/values/en.xml | 6 + 15 files changed, 978 insertions(+), 181 deletions(-) create mode 100644 app/src/main/java/net/helcel/beans/activity/sub/MapPickDialog.kt create mode 100644 app/src/main/java/net/helcel/beans/countries/GeoLocTree.kt create mode 100644 app/src/main/java/net/helcel/beans/helper/Prefs.kt create mode 100644 app/src/main/java/net/helcel/beans/map/MapAssets.kt create mode 100644 app/src/main/java/net/helcel/beans/map/MapRenderer.kt create mode 100644 app/src/main/java/net/helcel/beans/map/MapStyle.kt create mode 100644 app/src/main/java/net/helcel/beans/map/MapView.kt delete mode 100644 app/src/main/java/net/helcel/beans/svg/CSSWrapper.kt delete mode 100644 app/src/main/java/net/helcel/beans/svg/SVGWrapper.kt diff --git a/app/build.gradle b/app/build.gradle index 0f56de1..d37bb1c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/java/net/helcel/beans/activity/MainScreen.kt b/app/src/main/java/net/helcel/beans/activity/MainScreen.kt index 2f2e6aa..9d57930 100644 --- a/app/src/main/java/net/helcel/beans/activity/MainScreen.kt +++ b/app/src/main/java/net/helcel/beans/activity/MainScreen.kt @@ -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(null) - private var css by mutableStateOf(null) + private var world by mutableStateOf(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>(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() } } diff --git a/app/src/main/java/net/helcel/beans/activity/sub/EditPlaceScreen.kt b/app/src/main/java/net/helcel/beans/activity/sub/EditPlaceScreen.kt index edfafe2..69ad933 100644 --- a/app/src/main/java/net/helcel/beans/activity/sub/EditPlaceScreen.kt +++ b/app/src/main/java/net/helcel/beans/activity/sub/EditPlaceScreen.kt @@ -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, ), diff --git a/app/src/main/java/net/helcel/beans/activity/sub/MapPickDialog.kt b/app/src/main/java/net/helcel/beans/activity/sub/MapPickDialog.kt new file mode 100644 index 0000000..572510d --- /dev/null +++ b/app/src/main/java/net/helcel/beans/activity/sub/MapPickDialog.kt @@ -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): List { + val rows = LinkedHashMap() + 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, + 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)) + } + } + } + }, + ) + } +} diff --git a/app/src/main/java/net/helcel/beans/countries/GeoLocImporter.kt b/app/src/main/java/net/helcel/beans/countries/GeoLocImporter.kt index 82f0fc8..1cccd47 100644 --- a/app/src/main/java/net/helcel/beans/countries/GeoLocImporter.kt +++ b/app/src/main/java/net/helcel/beans/countries/GeoLocImporter.kt @@ -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() } } \ No newline at end of file diff --git a/app/src/main/java/net/helcel/beans/countries/GeoLocTree.kt b/app/src/main/java/net/helcel/beans/countries/GeoLocTree.kt new file mode 100644 index 0000000..4c59e8f --- /dev/null +++ b/app/src/main/java/net/helcel/beans/countries/GeoLocTree.kt @@ -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 = emptyMap() + private var parents: Map = emptyMap() + + fun rebuild() { + val byCode = HashMap() + val parent = HashMap() + 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 { + if (index.isEmpty()) rebuild() + val path = ArrayList(4) + var current: GeoLoc? = loc + while (current != null && current != World.WWW) { + path.add(current) + current = parents[current.code] + } + return path.reversed() + } +} diff --git a/app/src/main/java/net/helcel/beans/helper/Prefs.kt b/app/src/main/java/net/helcel/beans/helper/Prefs.kt new file mode 100644 index 0000000..1256da2 --- /dev/null +++ b/app/src/main/java/net/helcel/beans/helper/Prefs.kt @@ -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) diff --git a/app/src/main/java/net/helcel/beans/helper/Settings.kt b/app/src/main/java/net/helcel/beans/helper/Settings.kt index 4e0e6fe..88542ce 100644 --- a/app/src/main/java/net/helcel/beans/helper/Settings.kt +++ b/app/src/main/java/net/helcel/beans/helper/Settings.kt @@ -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, diff --git a/app/src/main/java/net/helcel/beans/map/MapAssets.kt b/app/src/main/java/net/helcel/beans/map/MapAssets.kt new file mode 100644 index 0000000..6717aa1 --- /dev/null +++ b/app/src/main/java/net/helcel/beans/map/MapAssets.kt @@ -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" + } + } +} diff --git a/app/src/main/java/net/helcel/beans/map/MapRenderer.kt b/app/src/main/java/net/helcel/beans/map/MapRenderer.kt new file mode 100644 index 0000000..8896348 --- /dev/null +++ b/app/src/main/java/net/helcel/beans/map/MapRenderer.kt @@ -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) + } +} diff --git a/app/src/main/java/net/helcel/beans/map/MapStyle.kt b/app/src/main/java/net/helcel/beans/map/MapStyle.kt new file mode 100644 index 0000000..5d20ac3 --- /dev/null +++ b/app/src/main/java/net/helcel/beans/map/MapStyle.kt @@ -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, +) { + + companion object { + + fun build(ctx: Context, land: Int, background: Int): MapStyle { + val regional = Settings.isRegional(ctx) + val fills = HashMap() + 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, + 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 + } + } +} diff --git a/app/src/main/java/net/helcel/beans/map/MapView.kt b/app/src/main/java/net/helcel/beans/map/MapView.kt new file mode 100644 index 0000000..b496070 --- /dev/null +++ b/app/src/main/java/net/helcel/beans/map/MapView.kt @@ -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) -> 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() + 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) + } +} diff --git a/app/src/main/java/net/helcel/beans/svg/CSSWrapper.kt b/app/src/main/java/net/helcel/beans/svg/CSSWrapper.kt deleted file mode 100644 index 76829e0..0000000 --- a/app/src/main/java/net/helcel/beans/svg/CSSWrapper.kt +++ /dev/null @@ -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 { - 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 - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/net/helcel/beans/svg/SVGWrapper.kt b/app/src/main/java/net/helcel/beans/svg/SVGWrapper.kt deleted file mode 100644 index 5162786..0000000 --- a/app/src/main/java/net/helcel/beans/svg/SVGWrapper.kt +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/app/src/main/res/values/en.xml b/app/src/main/res/values/en.xml index 9393f3d..800dbfe 100644 --- a/app/src/main/res/values/en.xml +++ b/app/src/main/res/values/en.xml @@ -21,6 +21,10 @@ Cascade statistics Include parents and children in the counts Enable multiple colors + key_touch_radius + Tap radius + How far around a tap on the map other places should be considered + %1$d dp About Appearance Features @@ -40,6 +44,8 @@ Select the group to keep. All others will be deleted and its mappings reassigned to the group you choose here. Are your sure you want to delete this group and remove all its country mappings? + Select a place + Tap the place to color, or a larger one that contains it. Are you sure you want to disable regions and reassign all regional mappings to the corresponding countries? Name