mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
feat: overlay country borders and cities on the globe picker
Adds public-domain Natural Earth vector overlays in the established terminal style, bundled as compact assets (works fully off-grid): - world_borders.geojson (56KB): admin-0 boundary lines, stroked as front-facing horizon-clipped runs in a muted theme color - world_cities.geojson (99KB, 1251 populated places): zoom-tiered by scale rank - capitals as accent dots, other cities as muted dots; name labels in Geist Mono fade in from regional zoom levels to keep the whole-globe view uncluttered
This commit is contained in:
parent
9ca1413a1f
commit
79bf154ce3
1
app/src/main/assets/world_borders.geojson
Normal file
1
app/src/main/assets/world_borders.geojson
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/assets/world_cities.geojson
Normal file
1
app/src/main/assets/world_cities.geojson
Normal file
File diff suppressed because one or more lines are too long
@ -107,6 +107,12 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
val land by produceState<List<LandData.Ring>?>(initialValue = null) {
|
||||
value = withContext(Dispatchers.IO) { LandData.load(context) }
|
||||
}
|
||||
val borders by produceState<List<LandData.Ring>>(initialValue = emptyList()) {
|
||||
value = withContext(Dispatchers.IO) { LandData.loadBorders(context) }
|
||||
}
|
||||
val cities by produceState<List<LandData.City>>(initialValue = emptyList()) {
|
||||
value = withContext(Dispatchers.IO) { LandData.loadCities(context) }
|
||||
}
|
||||
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val dark = colorScheme.background.luminance() < 0.5f
|
||||
@ -116,6 +122,7 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
accent = colorScheme.primary,
|
||||
land = Color(0xFF16241B),
|
||||
coastline = colorScheme.primary.copy(alpha = 0.45f),
|
||||
border = colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
oceanCenter = Color(0xFF0A1410),
|
||||
oceanEdge = Color(0xFF020604),
|
||||
atmosphere = colorScheme.primary,
|
||||
@ -130,6 +137,7 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
accent = colorScheme.primary,
|
||||
land = Color(0xFFBCD2C0),
|
||||
coastline = colorScheme.primary.copy(alpha = 0.5f),
|
||||
border = colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
oceanCenter = Color(0xFFEAF2EC),
|
||||
oceanEdge = Color(0xFFD4E2D7),
|
||||
atmosphere = colorScheme.primary,
|
||||
@ -155,6 +163,8 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
state = globeState,
|
||||
colors = globeColors,
|
||||
land = rings,
|
||||
borders = borders,
|
||||
cities = cities,
|
||||
labelTypeface = labelTypeface,
|
||||
labelTypefaceBold = labelTypefaceBold,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
|
||||
@ -46,6 +46,7 @@ data class GlobeColors(
|
||||
val accent: Color,
|
||||
val land: Color,
|
||||
val coastline: Color,
|
||||
val border: Color,
|
||||
val oceanCenter: Color,
|
||||
val oceanEdge: Color,
|
||||
val atmosphere: Color,
|
||||
@ -63,6 +64,8 @@ fun GlobeView(
|
||||
state: GlobeState,
|
||||
colors: GlobeColors,
|
||||
land: List<LandData.Ring>,
|
||||
borders: List<LandData.Ring>,
|
||||
cities: List<LandData.City>,
|
||||
labelTypeface: Typeface?,
|
||||
labelTypefaceBold: Typeface?,
|
||||
modifier: Modifier = Modifier
|
||||
@ -84,6 +87,8 @@ fun GlobeView(
|
||||
|
||||
val maxRingPoints = remember(land) { land.maxOfOrNull { it.size } ?: 0 }
|
||||
val scratch = remember(maxRingPoints) { FloatArray(maxOf(1, maxRingPoints) * 3) }
|
||||
val maxBorderPoints = remember(borders) { borders.maxOfOrNull { it.size } ?: 0 }
|
||||
val borderScratch = remember(maxBorderPoints) { FloatArray(maxOf(1, maxBorderPoints) * 3) }
|
||||
|
||||
val labelPaint = remember {
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
|
||||
@ -262,6 +267,11 @@ fun GlobeView(
|
||||
drawLandRing(ring, scratch, cx, cy, r, cLat, cLon, colors, clip)
|
||||
}
|
||||
|
||||
// Country borders
|
||||
for (line in borders) {
|
||||
drawBorderLine(line, borderScratch, cx, cy, r, cLat, cLon, colors, clip)
|
||||
}
|
||||
|
||||
// Sphere shading: dark limb + night side for 3D depth
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
@ -287,6 +297,12 @@ fun GlobeView(
|
||||
center = Offset(cx, cy)
|
||||
)
|
||||
|
||||
// Cities (dots + names) over the shaded sphere
|
||||
drawCities(
|
||||
cities, state, cx, cy, r, cLat, cLon, colors,
|
||||
labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density
|
||||
)
|
||||
|
||||
// Geohash cells
|
||||
if (state.selectedGeohash.isNotEmpty()) {
|
||||
drawGeohashGrid(state, cx, cy, r, cLat, cLon, colors, clip)
|
||||
@ -377,17 +393,19 @@ private fun limbPoint(behind: DiscPt, front: DiscPt): Pair<Float, Float> {
|
||||
/**
|
||||
* Splits a projected polygon into front-facing runs. Runs that touch the limb are
|
||||
* padded with the horizon intersection point so they can be closed along the horizon.
|
||||
* Pass [closed] = false for open polylines (border lines).
|
||||
*/
|
||||
private fun buildFrontRuns(pts: List<DiscPt>): List<MutableList<Pair<Float, Float>>> {
|
||||
private fun buildFrontRuns(pts: List<DiscPt>, closed: Boolean = true): List<MutableList<Pair<Float, Float>>> {
|
||||
if (pts.isEmpty()) return emptyList()
|
||||
val n = pts.size
|
||||
val runs = mutableListOf<MutableList<Pair<Float, Float>>>()
|
||||
var run = mutableListOf<Pair<Float, Float>>()
|
||||
var prev = pts[n - 1]
|
||||
var prev: DiscPt? = if (closed) pts[n - 1] else null
|
||||
for (i in 0 until n) {
|
||||
val cur = pts[i]
|
||||
val p = prev
|
||||
if (cur.front) {
|
||||
if (run.isEmpty() && !prev.front) run.add(limbPoint(prev, cur))
|
||||
if (run.isEmpty() && p != null && !p.front) run.add(limbPoint(p, cur))
|
||||
// Antimeridian wrap: consecutive front points can jump across the whole
|
||||
// disc when a polygon crosses ±180° — break the run instead of drawing
|
||||
// a chord through the view.
|
||||
@ -402,8 +420,8 @@ private fun buildFrontRuns(pts: List<DiscPt>): List<MutableList<Pair<Float, Floa
|
||||
}
|
||||
run.add(cur.x to cur.y)
|
||||
} else {
|
||||
if (run.isNotEmpty()) {
|
||||
run.add(limbPoint(prev, cur))
|
||||
if (run.isNotEmpty() && p != null) {
|
||||
run.add(limbPoint(p, cur))
|
||||
runs.add(run)
|
||||
run = mutableListOf()
|
||||
}
|
||||
@ -411,7 +429,7 @@ private fun buildFrontRuns(pts: List<DiscPt>): List<MutableList<Pair<Float, Floa
|
||||
prev = cur
|
||||
}
|
||||
if (run.isNotEmpty()) {
|
||||
if (runs.isNotEmpty() && pts[0].front && pts[n - 1].front) {
|
||||
if (closed && runs.isNotEmpty() && pts[0].front && pts[n - 1].front) {
|
||||
runs[0] = (run + runs[0]).toMutableList()
|
||||
} else {
|
||||
runs.add(run)
|
||||
@ -585,6 +603,97 @@ private fun DrawScope.strokeRuns(
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawBorderLine(
|
||||
line: LandData.Ring,
|
||||
scratch: FloatArray,
|
||||
cx: Float, cy: Float, r: Float,
|
||||
cLat: Double, cLon: Double,
|
||||
colors: GlobeColors,
|
||||
clip: ClipRect
|
||||
) {
|
||||
val n = line.size
|
||||
if (n < 2 || n * 3 > scratch.size) return
|
||||
|
||||
var anyFront = false
|
||||
val pts = ArrayList<DiscPt>(n)
|
||||
var i = 0
|
||||
while (i < n) {
|
||||
val lat = line.coords[i * 2].toDouble()
|
||||
val lon = line.coords[i * 2 + 1].toDouble()
|
||||
val p = GlobeMath.projectRaw(lat, lon, cLat, cLon)
|
||||
val front = p.cosC > 0.005f
|
||||
pts.add(DiscPt(p.x, p.y, front))
|
||||
if (p.cosC >= 0f) anyFront = true
|
||||
i++
|
||||
}
|
||||
if (!anyFront) return
|
||||
|
||||
val runs = buildFrontRuns(pts, closed = false)
|
||||
strokeRuns(runs, cx, cy, r, colors.border, 1.2f, clip)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawCities(
|
||||
cities: List<LandData.City>,
|
||||
state: GlobeState,
|
||||
cx: Float, cy: Float, r: Float,
|
||||
cLat: Double, cLon: Double,
|
||||
colors: GlobeColors,
|
||||
labelPaint: Paint,
|
||||
haloPaint: Paint,
|
||||
typeface: Typeface?,
|
||||
textSize: Float,
|
||||
density: Float
|
||||
) {
|
||||
if (cities.isEmpty()) return
|
||||
val zoom = state.zoom
|
||||
val maxRank = when {
|
||||
zoom < 2f -> 1
|
||||
zoom < 8f -> 3
|
||||
zoom < 40f -> 4
|
||||
else -> 10
|
||||
}
|
||||
val canvas = drawContext.canvas.nativeCanvas
|
||||
for (city in cities) {
|
||||
if (city.rank > maxRank) continue
|
||||
val p = GlobeMath.project(city.lat.toDouble(), city.lon.toDouble(), cLat, cLon) ?: continue
|
||||
if (p.cosC < 0.03f) continue
|
||||
val sx = cx + p.x * r
|
||||
val sy = cy + p.y * r
|
||||
if (sx < -50 || sx > size.width + 50 || sy < -50 || sy > size.height + 50) continue
|
||||
|
||||
val alpha = p.cosC.coerceIn(0.25f, 1f)
|
||||
val important = city.capital || city.megacity
|
||||
val dotRadius = (if (important) 2.6f else 1.8f) * density
|
||||
val dotColor = if (city.capital) colors.accent.copy(alpha = alpha)
|
||||
else colors.label.copy(alpha = alpha * 0.85f)
|
||||
drawCircle(dotColor, radius = dotRadius, center = Offset(sx, sy))
|
||||
|
||||
if (zoom >= 6f || (important && zoom >= 2.5f)) {
|
||||
labelPaint.textSize = textSize
|
||||
labelPaint.typeface = typeface
|
||||
labelPaint.textAlign = Paint.Align.LEFT
|
||||
labelPaint.color = android.graphics.Color.argb(
|
||||
(200 * alpha).toInt(),
|
||||
(colors.label.red * 255).toInt(), (colors.label.green * 255).toInt(), (colors.label.blue * 255).toInt()
|
||||
)
|
||||
haloPaint.textSize = textSize
|
||||
haloPaint.typeface = typeface
|
||||
haloPaint.textAlign = Paint.Align.LEFT
|
||||
haloPaint.strokeWidth = textSize * 0.16f
|
||||
haloPaint.color = android.graphics.Color.argb(
|
||||
(140 * alpha).toInt(),
|
||||
(colors.labelHalo.red * 255).toInt(), (colors.labelHalo.green * 255).toInt(), (colors.labelHalo.blue * 255).toInt()
|
||||
)
|
||||
val tx = sx + dotRadius + 3 * density
|
||||
val ty = sy - ((labelPaint.descent() + labelPaint.ascent()) / 2f)
|
||||
canvas.drawText(city.name, tx, ty, haloPaint)
|
||||
canvas.drawText(city.name, tx, ty, labelPaint)
|
||||
}
|
||||
}
|
||||
labelPaint.textAlign = Paint.Align.CENTER
|
||||
haloPaint.textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
private fun DrawScope.drawLandRing(
|
||||
ring: LandData.Ring,
|
||||
scratch: FloatArray,
|
||||
|
||||
@ -11,9 +11,17 @@ object LandData {
|
||||
|
||||
data class Ring(val coords: FloatArray, val size: Int)
|
||||
|
||||
data class City(val name: String, val lat: Float, val lon: Float, val rank: Int, val capital: Boolean, val megacity: Boolean)
|
||||
|
||||
@Volatile
|
||||
private var cached: List<Ring>? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedBorders: List<Ring>? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedCities: List<City>? = null
|
||||
|
||||
/** Returns land polygon rings; each ring is a flat array of (lat, lon) pairs. */
|
||||
fun load(context: Context): List<Ring> {
|
||||
cached?.let { return it }
|
||||
@ -40,6 +48,71 @@ object LandData {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns country border lines (Natural Earth admin-0 boundary lines, public domain). */
|
||||
fun loadBorders(context: Context): List<Ring> {
|
||||
cachedBorders?.let { return it }
|
||||
synchronized(this) {
|
||||
cachedBorders?.let { return it }
|
||||
val lines = mutableListOf<Ring>()
|
||||
val text = context.assets.open("world_borders.geojson").bufferedReader().use { it.readText() }
|
||||
val root = JSONObject(text)
|
||||
val geometries = root.getJSONArray("geometries")
|
||||
for (i in 0 until geometries.length()) {
|
||||
val geom = geometries.getJSONObject(i)
|
||||
when (geom.getString("type")) {
|
||||
"LineString" -> parseLine(geom.getJSONArray("coordinates"), lines)
|
||||
"MultiLineString" -> {
|
||||
val parts = geom.getJSONArray("coordinates")
|
||||
for (j in 0 until parts.length()) {
|
||||
parseLine(parts.getJSONArray(j), lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cachedBorders = lines
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns populated places (Natural Earth 50m, public domain) with name and scale rank. */
|
||||
fun loadCities(context: Context): List<City> {
|
||||
cachedCities?.let { return it }
|
||||
synchronized(this) {
|
||||
cachedCities?.let { return it }
|
||||
val cities = mutableListOf<City>()
|
||||
val text = context.assets.open("world_cities.geojson").bufferedReader().use { it.readText() }
|
||||
val root = JSONObject(text)
|
||||
val arr = root.getJSONArray("cities")
|
||||
for (i in 0 until arr.length()) {
|
||||
val c = arr.getJSONObject(i)
|
||||
cities.add(
|
||||
City(
|
||||
name = c.getString("n"),
|
||||
lat = c.getDouble("lat").toFloat(),
|
||||
lon = c.getDouble("lon").toFloat(),
|
||||
rank = c.getInt("r"),
|
||||
capital = c.optInt("cap", 0) == 1,
|
||||
megacity = c.optInt("mega", 0) == 1
|
||||
)
|
||||
)
|
||||
}
|
||||
cachedCities = cities
|
||||
return cities
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseLine(lineJson: org.json.JSONArray, out: MutableList<Ring>) {
|
||||
val n = lineJson.length()
|
||||
if (n < 2) return
|
||||
val coords = FloatArray(n * 2)
|
||||
for (p in 0 until n) {
|
||||
val pt = lineJson.getJSONArray(p)
|
||||
coords[p * 2] = pt.getDouble(1).toFloat() // lat
|
||||
coords[p * 2 + 1] = pt.getDouble(0).toFloat() // lon
|
||||
}
|
||||
out.add(Ring(coords, n))
|
||||
}
|
||||
|
||||
private fun parsePolygon(ringsJson: org.json.JSONArray, out: MutableList<Ring>) {
|
||||
for (r in 0 until ringsJson.length()) {
|
||||
val ringJson = ringsJson.getJSONArray(r)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user