mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
Merge pull request #823 from permissionlesstech/ui/geohash-globe-picker
feat: animated 3D globe geohash picker with borders and cities
This commit is contained in:
commit
2176160ac0
@ -1,225 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
:root { --text: #333; }
|
||||
html, body, #map { height: 100%; margin: 0; padding: 0; background: #ffffff; }
|
||||
.leaflet-container { background: #ffffff; }
|
||||
.leaflet-div-icon { background: transparent; border: none; }
|
||||
.gh-label { background: transparent; border: none; pointer-events: none; filter: none; }
|
||||
.gh-text {
|
||||
color: #444444;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 2px #ffffff, 0 0 2px #ffffff, 0 0 2px #ffffff;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
.dark .gh-text {
|
||||
color: #dddddd;
|
||||
text-shadow: 0 0 2px #000000, 0 0 2px #000000, 0 0 2px #000000;
|
||||
}
|
||||
.gh-text-selected {
|
||||
color: #00C851 !important;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
// Minimal geohash (bounds/encode/adjacent)
|
||||
(function () {
|
||||
const base32 = "0123456789bcdefghjkmnpqrstuvwxyz";
|
||||
function bounds(geohash) {
|
||||
let evenBit = true; let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
|
||||
geohash = geohash.toLowerCase();
|
||||
for (let i = 0; i < geohash.length; i++) {
|
||||
const idx = base32.indexOf(geohash.charAt(i));
|
||||
if (idx == -1) throw new Error("Invalid geohash");
|
||||
for (let n = 4; n >= 0; n--) {
|
||||
const bitN = (idx >> n) & 1;
|
||||
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (bitN == 1) lonMin = lonMid; else lonMax = lonMid; }
|
||||
else { const latMid = (latMin + latMax) / 2; if (bitN == 1) latMin = latMid; else latMax = latMid; }
|
||||
evenBit = !evenBit;
|
||||
}
|
||||
}
|
||||
return { sw: { lat: latMin, lng: lonMin }, ne: { lat: latMax, lng: lonMax } };
|
||||
}
|
||||
function encode(lat, lon, precision) {
|
||||
let idx = 0, bit = 0, evenBit = true, hash = "";
|
||||
let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
|
||||
while (hash.length < precision) {
|
||||
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (lon >= lonMid) { idx = idx * 2 + 1; lonMin = lonMid; } else { idx = idx * 2; lonMax = lonMid; } }
|
||||
else { const latMid = (latMin + latMax) / 2; if (lat >= latMid) { idx = idx * 2 + 1; latMin = latMid; } else { idx = idx * 2; latMax = latMid; } }
|
||||
evenBit = !evenBit; if (++bit == 5) { hash += base32.charAt(idx); bit = 0; idx = 0; }
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
function adjacent(hash, dir) {
|
||||
const neighbour = { n:["p0r21436x8zb9dcf5h7kjnmqesgutwvy","bc01fg45238967deuvhjyznpkmstqrwx"], s:["14365h7k9dcfesgujnmqp0r2twvyx8zb","238967debc01fg45kmstqrwxuvhjyznp"], e:["bc01fg45238967deuvhjyznpkmstqrwx","p0r21436x8zb9dcf5h7kjnmqesgutwvy"], w:["238967debc01fg45kmstqrwxuvhjyznp","14365h7k9dcfesgujnmqp0r2twvyx8zb"] };
|
||||
const border = { n:["prxz","bcfguvyz"], s:["028b","0145hjnp"], e:["bcfguvyz","prxz"], w:["0145hjnp","028b"] };
|
||||
hash = hash.toLowerCase(); const lastCh = hash.slice(-1); let parent = hash.slice(0, -1); const type = hash.length % 2;
|
||||
if (border[dir][type].indexOf(lastCh) != -1 && parent != "") parent = adjacent(parent, dir);
|
||||
return parent + base32.charAt(neighbour[dir][type].indexOf(lastCh));
|
||||
}
|
||||
window.__geohash = { bounds, encode, adjacent };
|
||||
})();
|
||||
|
||||
const map = L.map("map", { zoomControl: true, minZoom: 2, maxZoom: 21 }).setView([0, 0], 3);
|
||||
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", { maxZoom: 21, attribution: "© OpenStreetMap © Carto", opacity: 1.0 }).addTo(map);
|
||||
|
||||
let selectedGeohash = "";
|
||||
let gridLayer = L.layerGroup().addTo(map);
|
||||
let pinnedPrecision = null;
|
||||
let outlineColor = "#00C851";
|
||||
|
||||
function getNeighbors(hash) {
|
||||
const neighbors = [];
|
||||
// N, S, E, W
|
||||
neighbors.push(window.__geohash.adjacent(hash, 'n'));
|
||||
neighbors.push(window.__geohash.adjacent(hash, 's'));
|
||||
neighbors.push(window.__geohash.adjacent(hash, 'e'));
|
||||
neighbors.push(window.__geohash.adjacent(hash, 'w'));
|
||||
// Diagonals
|
||||
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'e'));
|
||||
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'w'));
|
||||
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'e'));
|
||||
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'w'));
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
function pickPrecisionForViewport() {
|
||||
const c = map.getCenter();
|
||||
const minPx = 80;
|
||||
const maxPx = 240;
|
||||
let chosen = 1;
|
||||
let lastAboveMin = 1;
|
||||
for (let p = 1; p <= 12; p++) {
|
||||
const gh = window.__geohash.encode(c.lat, c.lng, p);
|
||||
const b = window.__geohash.bounds(gh);
|
||||
const pSw = map.latLngToLayerPoint([b.sw.lat, b.sw.lng]);
|
||||
const pNe = map.latLngToLayerPoint([b.ne.lat, b.ne.lng]);
|
||||
const cellPx = Math.min(Math.abs(pNe.x - pSw.x), Math.abs(pSw.y - pNe.y));
|
||||
if (cellPx >= minPx && cellPx <= maxPx) { chosen = p; break; }
|
||||
if (cellPx >= minPx) { lastAboveMin = p; }
|
||||
if (cellPx < minPx) { chosen = lastAboveMin; break; }
|
||||
if (p === 12) { chosen = 12; }
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
function notifySelection() {
|
||||
if (window.Android && window.Android.onGeohashChanged && selectedGeohash) {
|
||||
window.Android.onGeohashChanged(selectedGeohash);
|
||||
}
|
||||
}
|
||||
|
||||
function zoomForPrecision(p) {
|
||||
if (p <= 1) return 1; if (p === 2) return 2; if (p === 3) return 3; if (p === 4) return 4;
|
||||
if (p === 5) return 5; if (p === 6) return 7; if (p === 7) return 9; if (p === 8) return 11;
|
||||
if (p === 9) return 13; if (p === 10) return 15; if (p === 11) return 17;
|
||||
return 18;
|
||||
}
|
||||
|
||||
function updateOverlay() {
|
||||
gridLayer.clearLayers();
|
||||
const c = map.getCenter();
|
||||
const usePinned = pinnedPrecision !== null;
|
||||
const p = usePinned ? pinnedPrecision : pickPrecisionForViewport();
|
||||
selectedGeohash = window.__geohash.encode(c.lat, c.lng, p);
|
||||
notifySelection();
|
||||
|
||||
const centerBounds = window.__geohash.bounds(selectedGeohash);
|
||||
const centerLon = (centerBounds.sw.lng + centerBounds.ne.lng) / 2;
|
||||
const centerLat = (centerBounds.sw.lat + centerBounds.ne.lat) / 2;
|
||||
|
||||
const allHashes = [selectedGeohash, ...getNeighbors(selectedGeohash)];
|
||||
|
||||
const filteredHashes = allHashes.filter(gh => {
|
||||
if (!gh) return false;
|
||||
try {
|
||||
const b = window.__geohash.bounds(gh);
|
||||
const lon = (b.sw.lng + b.ne.lng) / 2;
|
||||
const lat = (b.sw.lat + b.ne.lat) / 2;
|
||||
if (Math.abs(lon - centerLon) > 180) return false; // anti-meridian wrap
|
||||
if (Math.abs(lat - centerLat) > 90) return false; // pole wrap
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
});
|
||||
|
||||
filteredHashes.forEach(gh => {
|
||||
const b = window.__geohash.bounds(gh);
|
||||
const sw = [b.sw.lat, b.sw.lng];
|
||||
const ne = [b.ne.lat, b.ne.lng];
|
||||
const isSelected = (gh === selectedGeohash);
|
||||
|
||||
const rect = L.rectangle([sw, ne], {
|
||||
color: isSelected ? outlineColor : '#cccccc',
|
||||
weight: isSelected ? 3 : 1,
|
||||
fillOpacity: 0.0,
|
||||
opacity: 0.9,
|
||||
interactive: false
|
||||
});
|
||||
gridLayer.addLayer(rect);
|
||||
|
||||
const center = [(b.sw.lat + b.ne.lat) / 2, (b.sw.lng + b.ne.lng) / 2];
|
||||
const labelClass = isSelected ? 'gh-text gh-text-selected' : 'gh-text';
|
||||
const label = L.marker(center, {
|
||||
icon: L.divIcon({
|
||||
className: 'gh-label',
|
||||
html: `<span class="${labelClass}">${gh}</span>`
|
||||
}),
|
||||
interactive: false
|
||||
});
|
||||
gridLayer.addLayer(label);
|
||||
});
|
||||
}
|
||||
|
||||
map.on("movestart", () => { pinnedPrecision = null; });
|
||||
map.on("zoomstart", () => { pinnedPrecision = null; });
|
||||
map.on("moveend", updateOverlay);
|
||||
map.on("zoomend", updateOverlay);
|
||||
|
||||
function setCenter(lat, lng) { map.setView([lat, lng], map.getZoom()); }
|
||||
function setPrecision(p) {
|
||||
const clamped = Math.max(1, Math.min(12, p|0));
|
||||
const targetZoom = zoomForPrecision(clamped);
|
||||
map.setZoom(targetZoom);
|
||||
}
|
||||
function focusGeohash(gh) {
|
||||
if (!gh || typeof gh !== 'string') return;
|
||||
const g = gh.toLowerCase();
|
||||
const b = window.__geohash.bounds(g);
|
||||
pinnedPrecision = g.length;
|
||||
map.fitBounds([[b.sw.lat, b.sw.lng],[b.ne.lat, b.ne.lng]], { animate: false, padding: [8,8] });
|
||||
selectedGeohash = g;
|
||||
}
|
||||
function getGeohash() { return selectedGeohash; }
|
||||
|
||||
// Android side will call this with 'dark' or 'light'
|
||||
function setMapTheme(theme) {
|
||||
document.body.className = theme;
|
||||
}
|
||||
|
||||
window.setCenter = setCenter;
|
||||
window.setPrecision = setPrecision;
|
||||
window.focusGeohash = focusGeohash;
|
||||
window.getGeohash = getGeohash;
|
||||
window.setMapTheme = setMapTheme;
|
||||
|
||||
function cleanup() {
|
||||
try { map.off(); } catch (_) {}
|
||||
try { gridLayer.clearLayers(); } catch (_) {}
|
||||
try { map.remove(); } catch (_) {}
|
||||
}
|
||||
window.cleanup = cleanup;
|
||||
|
||||
map.whenReady(updateOverlay);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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
1
app/src/main/assets/world_land.geojson
Normal file
1
app/src/main/assets/world_land.geojson
Normal file
File diff suppressed because one or more lines are too long
@ -1,50 +1,48 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.ui.theme.BitchatTheme
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import com.bitchat.android.R
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import com.bitchat.android.geohash.Geohash
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.ui.globe.GlobeColors
|
||||
import com.bitchat.android.ui.globe.GlobeState
|
||||
import com.bitchat.android.ui.globe.GlobeView
|
||||
import com.bitchat.android.ui.globe.LandData
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.ui.theme.BitchatTheme
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
|
||||
companion object {
|
||||
@ -52,13 +50,13 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
const val EXTRA_RESULT_GEOHASH = "result_geohash"
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val initialGeohash = intent.getStringExtra(EXTRA_INITIAL_GEOHASH)?.trim()?.lowercase()
|
||||
var geohashToFocus: String? = null
|
||||
var (initLat, initLon) = 0.0 to 0.0
|
||||
var initLat = 20.0
|
||||
var initLon = 0.0
|
||||
|
||||
if (!initialGeohash.isNullOrEmpty()) {
|
||||
geohashToFocus = initialGeohash
|
||||
@ -84,201 +82,206 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val initialPrecision = geohashToFocus?.length ?: 5
|
||||
val initialPrecision = (geohashToFocus?.length ?: 2).coerceIn(1, 12)
|
||||
val targetLat = initLat
|
||||
val targetLon = initLon
|
||||
|
||||
setContent {
|
||||
BitchatTheme {
|
||||
var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") }
|
||||
var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) }
|
||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val globeState = remember {
|
||||
GlobeState(
|
||||
targetLat = targetLat,
|
||||
targetLon = targetLon,
|
||||
initialPrecision = initialPrecision,
|
||||
startZoomedOut = true
|
||||
).apply {
|
||||
introTarget = Triple(targetLat, targetLon, initialPrecision)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(globeState) { globeState.attach(scope) }
|
||||
|
||||
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 standardGreen = colorScheme.primary
|
||||
|
||||
Scaffold { padding ->
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.cacheMode = WebSettings.LOAD_DEFAULT
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
settings.allowFileAccess = true
|
||||
settings.allowContentAccess = true
|
||||
webChromeClient = WebChromeClient()
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
val url = request?.url?.toString() ?: return true
|
||||
// Block navigation away from the local geohash picker asset
|
||||
return !url.startsWith("file:///android_asset/geohash_picker.html")
|
||||
}
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
// Initialize to last/initial geohash if provided, otherwise center
|
||||
if (!geohashToFocus.isNullOrEmpty()) {
|
||||
evaluateJavascript(
|
||||
"window.focusGeohash('${geohashToFocus}')",
|
||||
null
|
||||
)
|
||||
} else {
|
||||
evaluateJavascript(
|
||||
"window.setCenter(${initLat}, ${initLon})",
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
// Apply theme to map tiles
|
||||
val nightModeFlags = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
val theme = if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"
|
||||
evaluateJavascript("window.setMapTheme('" + theme + "')", null)
|
||||
}
|
||||
}
|
||||
addJavascriptInterface(object {
|
||||
@JavascriptInterface
|
||||
fun onGeohashChanged(geohash: String) {
|
||||
runOnUiThread {
|
||||
currentGeohash = geohash
|
||||
}
|
||||
}
|
||||
}, "Android")
|
||||
|
||||
loadUrl("file:///android_asset/geohash_picker.html")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
update = { webView ->
|
||||
webViewRef = webView
|
||||
// ensure it fills parent
|
||||
webView.updateLayoutParams<ViewGroup.LayoutParams> {
|
||||
width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
},
|
||||
onRelease = { webView ->
|
||||
// Best-effort cleanup to avoid leaks and timers
|
||||
try { webView.evaluateJavascript("window.cleanup && window.cleanup()", null) } catch (_: Throwable) {}
|
||||
try { webView.stopLoading() } catch (_: Throwable) {}
|
||||
try { webView.clearHistory() } catch (_: Throwable) {}
|
||||
try { webView.clearCache(true) } catch (_: Throwable) {}
|
||||
try { webView.loadUrl("about:blank") } catch (_: Throwable) {}
|
||||
try { webView.removeAllViews() } catch (_: Throwable) {}
|
||||
try { webView.destroy() } catch (_: Throwable) {}
|
||||
}
|
||||
val dark = colorScheme.background.luminance() < 0.5f
|
||||
val globeColors = remember(colorScheme, dark) {
|
||||
if (dark) {
|
||||
GlobeColors(
|
||||
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,
|
||||
graticule = colorScheme.onSurface.copy(alpha = 0.055f),
|
||||
grid = colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
label = colorScheme.onSurfaceVariant,
|
||||
labelHalo = colorScheme.background,
|
||||
star = colorScheme.onSurface
|
||||
)
|
||||
} else {
|
||||
GlobeColors(
|
||||
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,
|
||||
graticule = colorScheme.onSurface.copy(alpha = 0.08f),
|
||||
grid = colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
label = colorScheme.onSurfaceVariant,
|
||||
labelHalo = colorScheme.background,
|
||||
star = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Floating info pill
|
||||
Surface(
|
||||
val labelTypeface = remember { ResourcesCompat.getFont(context, R.font.geist_mono_medium) }
|
||||
val labelTypefaceBold = remember { ResourcesCompat.getFont(context, R.font.geist_mono_semibold) }
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(colorScheme.background)
|
||||
) {
|
||||
land?.let { rings ->
|
||||
GlobeView(
|
||||
state = globeState,
|
||||
colors = globeColors,
|
||||
land = rings,
|
||||
borders = borders,
|
||||
cities = cities,
|
||||
labelTypeface = labelTypeface,
|
||||
labelTypefaceBold = labelTypefaceBold,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
// Floating info pill
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.statusBarsPadding()
|
||||
.padding(top = 20.dp)
|
||||
.fillMaxWidth(0.8f),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 6.dp
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.pan_zoom_instruction),
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 20.dp)
|
||||
.fillMaxWidth(0.75f),
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Floating bottom controls
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 20.dp, start = 16.dp, end = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Geohash label (monospace, app style)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 6.dp
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.pan_zoom_instruction),
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Floating bottom controls
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 20.dp, start = 16.dp, end = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Geohash label (monospace, app style)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 6.dp
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location",
|
||||
text = if (globeState.selectedGeohash.isNotEmpty()) "#${globeState.selectedGeohash}" else "select location",
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (globeState.selectedGeohash.isNotEmpty()) {
|
||||
Text(
|
||||
text = "${levelForLength(globeState.precision).displayName} • ${coverageString(globeState.precision)}",
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Button row
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Decrease precision
|
||||
Button(
|
||||
onClick = { globeState.animatePrecision(globeState.precision - 1) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.cd_decrease_precision))
|
||||
}
|
||||
|
||||
// Button row
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
// Increase precision
|
||||
Button(
|
||||
onClick = { globeState.animatePrecision(globeState.precision + 1) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
// Decrease precision
|
||||
Button(
|
||||
onClick = {
|
||||
precision = (precision - 1).coerceAtLeast(1)
|
||||
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = standardGreen.copy(alpha = 0.12f),
|
||||
contentColor = standardGreen
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.cd_decrease_precision))
|
||||
}
|
||||
}
|
||||
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.cd_increase_precision))
|
||||
}
|
||||
|
||||
// Increase precision
|
||||
Button(
|
||||
onClick = {
|
||||
precision = (precision + 1).coerceAtMost(12)
|
||||
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = standardGreen.copy(alpha = 0.12f),
|
||||
contentColor = standardGreen
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.cd_increase_precision))
|
||||
// Select button
|
||||
Button(
|
||||
onClick = {
|
||||
val gh = globeState.selectedGeohash
|
||||
if (gh.isNotEmpty()) {
|
||||
val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) }
|
||||
setResult(Activity.RESULT_OK, result)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
// Select button
|
||||
Button(
|
||||
onClick = {
|
||||
webViewRef?.evaluateJavascript("window.getGeohash()") { value ->
|
||||
val gh = value?.trim('"') ?: currentGeohash
|
||||
val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) }
|
||||
setResult(Activity.RESULT_OK, result)
|
||||
finish()
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.cd_select_geohash))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.select),
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = globeState.selectedGeohash.isNotEmpty(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.cd_select_geohash))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.select),
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -286,4 +289,36 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun levelForLength(length: Int): GeohashChannelLevel {
|
||||
return when (length) {
|
||||
in 0..2 -> GeohashChannelLevel.REGION
|
||||
in 3..4 -> GeohashChannelLevel.PROVINCE
|
||||
5 -> GeohashChannelLevel.CITY
|
||||
6 -> GeohashChannelLevel.NEIGHBORHOOD
|
||||
7 -> GeohashChannelLevel.BLOCK
|
||||
else -> GeohashChannelLevel.BUILDING
|
||||
}
|
||||
}
|
||||
|
||||
private fun coverageString(precision: Int): String {
|
||||
val maxMeters = when (precision) {
|
||||
2 -> 1_250_000.0
|
||||
3 -> 156_000.0
|
||||
4 -> 39_100.0
|
||||
5 -> 4_890.0
|
||||
6 -> 1_220.0
|
||||
7 -> 153.0
|
||||
8 -> 38.2
|
||||
9 -> 4.77
|
||||
10 -> 1.19
|
||||
else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble())
|
||||
}
|
||||
val km = maxMeters / 1000.0
|
||||
return when {
|
||||
km >= 100 -> "~${String.format(java.util.Locale.US, "%.0f", km)} km"
|
||||
km >= 1 -> "~${String.format(java.util.Locale.US, "%.1f", km)} km"
|
||||
else -> "~${String.format(java.util.Locale.US, "%.0f", maxMeters)} m"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
app/src/main/java/com/bitchat/android/ui/globe/GlobeMath.kt
Normal file
100
app/src/main/java/com/bitchat/android/ui/globe/GlobeMath.kt
Normal file
@ -0,0 +1,100 @@
|
||||
package com.bitchat.android.ui.globe
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
/**
|
||||
* Orthographic globe projection and geohash zoom math for the 3D globe picker.
|
||||
*
|
||||
* The globe is rendered as a disc of radius R centered on screen. Points are
|
||||
* projected with an orthographic projection around a view center lat/lon.
|
||||
*/
|
||||
object GlobeMath {
|
||||
|
||||
data class Projection(val x: Float, val y: Float, val cosC: Float)
|
||||
|
||||
/**
|
||||
* Projects (lat, lon) onto the view disc of a globe centered at (centerLat, centerLon).
|
||||
* Returns x/y in units of globe radius (screen y down). [Projection.cosC] is negative
|
||||
* when the point is on the far side of the sphere.
|
||||
*/
|
||||
fun projectRaw(latDeg: Double, lonDeg: Double, centerLatDeg: Double, centerLonDeg: Double): Projection {
|
||||
val phi = Math.toRadians(latDeg)
|
||||
val phi0 = Math.toRadians(centerLatDeg)
|
||||
val dLambda = Math.toRadians(normalizeLon(lonDeg - centerLonDeg))
|
||||
val cosC = sin(phi0) * sin(phi) + cos(phi0) * cos(phi) * cos(dLambda)
|
||||
val x = cos(phi) * sin(dLambda)
|
||||
val y = -(cos(phi0) * sin(phi) - sin(phi0) * cos(phi) * cos(dLambda))
|
||||
return Projection(x.toFloat(), y.toFloat(), cosC.toFloat())
|
||||
}
|
||||
|
||||
/** Like [projectRaw] but null when the point is behind the limb. */
|
||||
fun project(latDeg: Double, lonDeg: Double, centerLatDeg: Double, centerLonDeg: Double): Projection? {
|
||||
val p = projectRaw(latDeg, lonDeg, centerLatDeg, centerLonDeg)
|
||||
return if (p.cosC >= 0f) p else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse projection: disc coordinates (units of radius, screen y down) back to lat/lon.
|
||||
* Returns null if the point lies outside the disc.
|
||||
*/
|
||||
fun unproject(x: Double, y: Double, centerLatDeg: Double, centerLonDeg: Double): Pair<Double, Double>? {
|
||||
val rho = sqrt(x * x + y * y)
|
||||
if (rho > 1.0) return null
|
||||
val c = asin(rho.coerceIn(-1.0, 1.0))
|
||||
val phi0 = Math.toRadians(centerLatDeg)
|
||||
val sinC = sin(c)
|
||||
val cosC = cos(c)
|
||||
val lat: Double
|
||||
val lonOffset: Double
|
||||
if (rho < 1e-9) {
|
||||
lat = centerLatDeg
|
||||
lonOffset = 0.0
|
||||
} else {
|
||||
lat = Math.toDegrees(asin(cosC * sin(phi0) + (-y) * sinC * cos(phi0) / rho))
|
||||
lonOffset = Math.toDegrees(atan2(x * sinC, rho * cos(phi0) * cosC - (-y) * sin(phi0) * sinC))
|
||||
}
|
||||
return lat to normalizeLon(centerLonDeg + lonOffset)
|
||||
}
|
||||
|
||||
fun normalizeLon(lon: Double): Double {
|
||||
var x = lon % 360.0
|
||||
if (x > 180.0) x -= 360.0
|
||||
if (x < -180.0) x += 360.0
|
||||
return x
|
||||
}
|
||||
|
||||
/** Longitude span of a geohash cell in degrees. */
|
||||
fun cellSpanLon(precision: Int): Double = 360.0 / 2.0.pow(ceil(5.0 * precision / 2.0))
|
||||
|
||||
/** Latitude span of a geohash cell in degrees. */
|
||||
fun cellSpanLat(precision: Int): Double = 180.0 / 2.0.pow(floor(5.0 * precision / 2.0))
|
||||
|
||||
/**
|
||||
* Picks the geohash precision whose cells render at a comfortable on-screen size
|
||||
* for the current zoom: the largest precision whose cell is at least ~22% of the
|
||||
* screen's smallest dimension.
|
||||
*/
|
||||
fun autoPrecision(globeRadiusPx: Float, screenMinPx: Float): Int {
|
||||
val targetPx = screenMinPx * 0.22f
|
||||
var best = 1
|
||||
for (p in 1..MAX_PRECISION) {
|
||||
val spanPx = (cellSpanLat(p) * (Math.PI / 180.0) * globeRadiusPx).toFloat()
|
||||
if (spanPx >= targetPx) best = p else break
|
||||
}
|
||||
return best.coerceIn(1, MAX_PRECISION)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom factor (globe radius multiplier over the fit-to-screen base radius) that frames
|
||||
* a cell of the given precision nicely: the cell spans ~1/3 of the screen.
|
||||
*/
|
||||
fun zoomForPrecision(precision: Int, baseRadiusPx: Float, screenMinPx: Float): Float {
|
||||
val spanRad = cellSpanLat(precision) * (Math.PI / 180.0)
|
||||
val targetRadius = (screenMinPx / 3.0) / spanRad
|
||||
return (targetRadius / baseRadiusPx).toFloat().coerceIn(MIN_ZOOM, MAX_ZOOM)
|
||||
}
|
||||
|
||||
const val MIN_ZOOM = 1f
|
||||
const val MAX_ZOOM = 120000f
|
||||
const val MAX_PRECISION = 12
|
||||
}
|
||||
185
app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt
Normal file
185
app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt
Normal file
@ -0,0 +1,185 @@
|
||||
package com.bitchat.android.ui.globe
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.bitchat.android.geohash.Geohash
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Hoisted state for the interactive 3D globe: view center, zoom, geohash precision
|
||||
* and the current selection. All mutation funnels through this class so rendering,
|
||||
* gestures and buttons stay in sync.
|
||||
*/
|
||||
class GlobeState(
|
||||
targetLat: Double,
|
||||
targetLon: Double,
|
||||
initialPrecision: Int,
|
||||
startZoomedOut: Boolean
|
||||
) {
|
||||
var centerLat by mutableFloatStateOf(if (startZoomedOut) (targetLat * 0.4).toFloat() else targetLat.toFloat())
|
||||
private set
|
||||
var centerLon by mutableFloatStateOf(if (startZoomedOut) GlobeMath.normalizeLon(targetLon - 70.0).toFloat() else targetLon.toFloat())
|
||||
private set
|
||||
var zoom by mutableFloatStateOf(if (startZoomedOut) GlobeMath.MIN_ZOOM else 1f)
|
||||
private set
|
||||
var precision by mutableIntStateOf(initialPrecision.coerceIn(1, GlobeMath.MAX_PRECISION))
|
||||
private set
|
||||
var selectedGeohash by mutableStateOf("")
|
||||
private set
|
||||
var isInteracting by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
internal var baseRadiusPx by mutableFloatStateOf(0f)
|
||||
internal var screenMinPx by mutableFloatStateOf(0f)
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
private var animJob: Job? = null
|
||||
|
||||
/** Pending cinematic intro target (lat, lon, precision); consumed when played. */
|
||||
var introTarget: Triple<Double, Double, Int>? = null
|
||||
private var introPlayed = false
|
||||
|
||||
fun playPendingIntroIfAny() {
|
||||
if (introPlayed) return
|
||||
val target = introTarget ?: return
|
||||
introPlayed = true
|
||||
introTarget = null
|
||||
playIntro(target.first, target.second, target.third)
|
||||
}
|
||||
|
||||
fun attach(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
}
|
||||
|
||||
fun setViewport(baseRadiusPx: Float, screenMinPx: Float) {
|
||||
if (baseRadiusPx <= 0f || screenMinPx <= 0f) return
|
||||
this.baseRadiusPx = baseRadiusPx
|
||||
this.screenMinPx = screenMinPx
|
||||
syncSelection()
|
||||
}
|
||||
|
||||
val globeRadiusPx: Float get() = baseRadiusPx * zoom
|
||||
|
||||
/** Direct rotation from drag gestures. Deltas are in screen px. */
|
||||
fun rotateBy(dxPx: Float, dyPx: Float) {
|
||||
val r = globeRadiusPx
|
||||
if (r <= 0f) return
|
||||
val degPerPx = 180.0 / (Math.PI * r)
|
||||
centerLon = GlobeMath.normalizeLon(centerLon - dxPx * degPerPx).toFloat()
|
||||
centerLat = (centerLat + dyPx * degPerPx).toFloat().coerceIn(MIN_LAT, MAX_LAT)
|
||||
syncSelection()
|
||||
}
|
||||
|
||||
/** Continuous zoom from pinch gestures; precision follows automatically. */
|
||||
fun zoomBy(factor: Float) {
|
||||
if (factor == 1f) return
|
||||
zoom = (zoom * factor).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM)
|
||||
syncPrecisionFromZoom()
|
||||
syncSelection()
|
||||
}
|
||||
|
||||
private fun syncPrecisionFromZoom() {
|
||||
if (baseRadiusPx <= 0f) return
|
||||
precision = GlobeMath.autoPrecision(globeRadiusPx, screenMinPx)
|
||||
}
|
||||
|
||||
/** Animate the view to a lat/lon, optionally to a zoom and precision. */
|
||||
fun animateTo(
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
targetZoom: Float? = null,
|
||||
targetPrecision: Int? = null,
|
||||
durationMs: Int = 550
|
||||
) {
|
||||
val s = scope ?: return
|
||||
animJob?.cancel()
|
||||
val startLat = centerLat
|
||||
val startLon = centerLon
|
||||
val dLon = GlobeMath.normalizeLon(lon - startLon)
|
||||
val startZoom = zoom
|
||||
val endZoom = (targetZoom ?: zoom).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM)
|
||||
animJob = s.launch {
|
||||
val anim = Animatable(0f)
|
||||
anim.animateTo(1f, tween(durationMs, easing = FastOutSlowInEasing)) {
|
||||
val t = value
|
||||
centerLat = (startLat + (lat.toFloat() - startLat) * t).coerceIn(MIN_LAT, MAX_LAT)
|
||||
centerLon = GlobeMath.normalizeLon(startLon + dLon * t).toFloat()
|
||||
// exponential interpolation feels natural for zoom
|
||||
zoom = startZoom * (endZoom / startZoom).pow(t)
|
||||
if (targetPrecision != null) {
|
||||
precision = targetPrecision.coerceIn(1, GlobeMath.MAX_PRECISION)
|
||||
} else {
|
||||
syncPrecisionFromZoom()
|
||||
}
|
||||
syncSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Button-driven precision change: adjusts precision and animates zoom to frame it. */
|
||||
fun animatePrecision(newPrecision: Int) {
|
||||
val p = newPrecision.coerceIn(1, GlobeMath.MAX_PRECISION)
|
||||
if (p == precision || baseRadiusPx <= 0f) return
|
||||
val targetZoom = GlobeMath.zoomForPrecision(p, baseRadiusPx, screenMinPx)
|
||||
animateTo(centerLat.toDouble(), centerLon.toDouble(), targetZoom, p, durationMs = 450)
|
||||
}
|
||||
|
||||
/** Cinematic intro: spin and zoom from a far view into the target location. */
|
||||
private fun playIntro(targetLat: Double, targetLon: Double, targetPrecision: Int) {
|
||||
if (baseRadiusPx <= 0f) {
|
||||
// viewport not ready yet; retry once attached to layout via caller
|
||||
return
|
||||
}
|
||||
val targetZoom = GlobeMath.zoomForPrecision(targetPrecision, baseRadiusPx, screenMinPx)
|
||||
animateTo(targetLat, targetLon, targetZoom, targetPrecision, durationMs = 1400)
|
||||
}
|
||||
|
||||
/** Inertial spin after a fling. Velocities are in px/ms. */
|
||||
fun fling(velocityX: Float, velocityY: Float) {
|
||||
val s = scope ?: return
|
||||
if (abs(velocityX) < 0.05f && abs(velocityY) < 0.05f) return
|
||||
animJob?.cancel()
|
||||
animJob = s.launch {
|
||||
var vx = velocityX
|
||||
var vy = velocityY
|
||||
var lastTime = System.nanoTime()
|
||||
while (abs(vx) > 0.02f || abs(vy) > 0.02f) {
|
||||
val now = System.nanoTime()
|
||||
val dtMs = ((now - lastTime) / 1_000_000f).coerceAtMost(50f)
|
||||
lastTime = now
|
||||
rotateBy(vx * dtMs, vy * dtMs)
|
||||
val decay = 0.94f.pow(dtMs / 16f)
|
||||
vx *= decay
|
||||
vy *= decay
|
||||
kotlinx.coroutines.delay(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelAnimations() {
|
||||
animJob?.cancel()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Close to the projection limit so polar geohash cells remain selectable;
|
||||
// clamping tighter would make syncSelection() encode the wrong cell.
|
||||
const val MIN_LAT = -89f
|
||||
const val MAX_LAT = 89f
|
||||
}
|
||||
|
||||
private fun syncSelection() {
|
||||
if (baseRadiusPx <= 0f) return
|
||||
val gh = Geohash.encode(centerLat.toDouble(), centerLon.toDouble(), precision)
|
||||
if (gh != selectedGeohash) selectedGeohash = gh
|
||||
}
|
||||
}
|
||||
841
app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt
Normal file
841
app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt
Normal file
@ -0,0 +1,841 @@
|
||||
package com.bitchat.android.ui.globe
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.os.SystemClock
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.calculatePan
|
||||
import androidx.compose.foundation.gestures.calculateZoom
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChanged
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.geohash.Geohash
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** Color palette for the globe, derived from the app theme by the caller. */
|
||||
data class GlobeColors(
|
||||
val accent: Color,
|
||||
val land: Color,
|
||||
val coastline: Color,
|
||||
val border: Color,
|
||||
val oceanCenter: Color,
|
||||
val oceanEdge: Color,
|
||||
val atmosphere: Color,
|
||||
val graticule: Color,
|
||||
val grid: Color,
|
||||
val label: Color,
|
||||
val labelHalo: Color,
|
||||
val star: Color
|
||||
)
|
||||
|
||||
private class Star(val x: Float, val y: Float, val radius: Float, val alpha: Float)
|
||||
|
||||
@Composable
|
||||
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
|
||||
) {
|
||||
val view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val stars = remember {
|
||||
val rnd = kotlin.random.Random(42)
|
||||
List(160) {
|
||||
Star(
|
||||
x = rnd.nextFloat(),
|
||||
y = rnd.nextFloat(),
|
||||
radius = 0.6f + rnd.nextFloat() * 1.5f,
|
||||
alpha = 0.15f + rnd.nextFloat() * 0.55f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
val haloPaint = remember {
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
style = Paint.Style.STROKE
|
||||
}
|
||||
}
|
||||
|
||||
val pulse by rememberInfiniteTransition(label = "crosshair").animateFloat(
|
||||
initialValue = 0.45f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(1100), RepeatMode.Reverse),
|
||||
label = "crosshairAlpha"
|
||||
)
|
||||
|
||||
LaunchedEffect(state) {
|
||||
// Fire intro once the viewport size is known.
|
||||
snapshotFlow { state.baseRadiusPx }.first { it > 0f }
|
||||
state.playPendingIntroIfAny()
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.selectedGeohash }
|
||||
.drop(1)
|
||||
.collect {
|
||||
view.performHapticFeedback(
|
||||
HapticFeedbackConstants.KEYBOARD_TAP,
|
||||
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val labelTextSize = with(density) { 12.5.sp.toPx() }
|
||||
val labelTextSizeSmall = with(density) { 10.sp.toPx() }
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.onSizeChanged { size: IntSize ->
|
||||
val minDim = min(size.width, size.height).toFloat()
|
||||
state.setViewport(minDim * 0.44f, minDim)
|
||||
}
|
||||
.pointerInput(state) {
|
||||
var lastTapTime = 0L
|
||||
var lastTapPos = Offset.Zero
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
state.cancelAnimations()
|
||||
state.isInteracting = true
|
||||
val downTime = SystemClock.uptimeMillis()
|
||||
val downPos = down.position
|
||||
var maxPointers = 1
|
||||
var moved = Offset.Zero
|
||||
val panTimes = ArrayDeque<Long>()
|
||||
val panVec = ArrayDeque<Offset>()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val pressed = event.changes.filter { it.pressed }
|
||||
if (pressed.isEmpty()) break
|
||||
maxPointers = maxOf(maxPointers, pressed.size)
|
||||
|
||||
val pan = event.calculatePan()
|
||||
val zoomChange = event.calculateZoom()
|
||||
|
||||
if (pan != Offset.Zero) {
|
||||
state.rotateBy(pan.x, pan.y)
|
||||
moved += pan
|
||||
val now = SystemClock.uptimeMillis()
|
||||
panTimes.addLast(now)
|
||||
panVec.addLast(pan)
|
||||
while (panTimes.isNotEmpty() && now - panTimes.first() > 120) {
|
||||
panTimes.removeFirst()
|
||||
panVec.removeFirst()
|
||||
}
|
||||
}
|
||||
if (zoomChange != 1f) {
|
||||
state.zoomBy(zoomChange)
|
||||
}
|
||||
event.changes.forEach { if (it.positionChanged()) it.consume() }
|
||||
}
|
||||
|
||||
state.isInteracting = false
|
||||
val upTime = SystemClock.uptimeMillis()
|
||||
val isTap = maxPointers == 1 &&
|
||||
upTime - downTime < 400 &&
|
||||
moved.getDistance() < viewConfiguration.touchSlop
|
||||
|
||||
if (isTap) {
|
||||
val cx = size.width / 2f
|
||||
val cy = size.height / 2f
|
||||
val r = state.globeRadiusPx
|
||||
if (r > 0f) {
|
||||
val latLon = GlobeMath.unproject(
|
||||
((downPos.x - cx) / r).toDouble(),
|
||||
((downPos.y - cy) / r).toDouble(),
|
||||
state.centerLat.toDouble(),
|
||||
state.centerLon.toDouble()
|
||||
)
|
||||
if (latLon != null) {
|
||||
val now = upTime
|
||||
val lastTap = lastTapTime
|
||||
val isDouble = now - lastTap < 350 &&
|
||||
(downPos - lastTapPos).getDistance() < viewConfiguration.touchSlop * 4
|
||||
lastTapTime = now
|
||||
lastTapPos = downPos
|
||||
if (isDouble) {
|
||||
val targetZoom = (state.zoom * 1.9f).coerceAtMost(GlobeMath.MAX_ZOOM)
|
||||
state.animateTo(latLon.first, latLon.second, targetZoom, null, 500)
|
||||
} else {
|
||||
state.animateTo(latLon.first, latLon.second, null, null, 450)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (panVec.isNotEmpty()) {
|
||||
var sx = 0f; var sy = 0f
|
||||
panVec.forEach { sx += it.x; sy += it.y }
|
||||
val windowMs = (SystemClock.uptimeMillis() - panTimes.first()).coerceAtLeast(1)
|
||||
state.fling(sx / windowMs, sy / windowMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val cx = size.width / 2f
|
||||
val cy = size.height / 2f
|
||||
val baseR = state.baseRadiusPx
|
||||
if (baseR <= 0f) return@Canvas
|
||||
val r = state.globeRadiusPx
|
||||
val cLat = state.centerLat.toDouble()
|
||||
val cLon = state.centerLon.toDouble()
|
||||
|
||||
// Starfield
|
||||
stars.forEach { s ->
|
||||
drawCircle(
|
||||
color = colors.star.copy(alpha = s.alpha),
|
||||
radius = s.radius * density.density,
|
||||
center = Offset(s.x * size.width, s.y * size.height)
|
||||
)
|
||||
}
|
||||
|
||||
// Atmosphere glow
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
0f to colors.atmosphere.copy(alpha = 0.30f),
|
||||
0.75f to colors.atmosphere.copy(alpha = 0.12f),
|
||||
1f to Color.Transparent,
|
||||
center = Offset(cx, cy),
|
||||
radius = r * 1.22f
|
||||
),
|
||||
radius = r * 1.22f,
|
||||
center = Offset(cx, cy)
|
||||
)
|
||||
|
||||
// Ocean sphere
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
0f to colors.oceanCenter,
|
||||
0.7f to colors.oceanCenter,
|
||||
1f to colors.oceanEdge,
|
||||
center = Offset(cx - r * 0.32f, cy - r * 0.38f),
|
||||
radius = r * 1.5f
|
||||
),
|
||||
radius = r,
|
||||
center = Offset(cx, cy)
|
||||
)
|
||||
|
||||
val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f)
|
||||
|
||||
// Graticule
|
||||
drawGraticule(cx, cy, r, cLat, cLon, colors.graticule, clip)
|
||||
|
||||
// Landmasses
|
||||
for (ring in land) {
|
||||
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(
|
||||
0f to Color.Transparent,
|
||||
0.62f to Color.Transparent,
|
||||
0.88f to Color.Black.copy(alpha = 0.34f),
|
||||
1f to Color.Black.copy(alpha = 0.72f),
|
||||
center = Offset(cx - r * 0.25f, cy - r * 0.3f),
|
||||
radius = r * 1.35f
|
||||
),
|
||||
radius = r + 1,
|
||||
center = Offset(cx, cy)
|
||||
)
|
||||
drawCircle(
|
||||
brush = Brush.linearGradient(
|
||||
0f to Color.Transparent,
|
||||
0.55f to Color.Transparent,
|
||||
1f to Color.Black.copy(alpha = 0.42f),
|
||||
start = Offset(cx - r * 0.7f, cy - r * 0.7f),
|
||||
end = Offset(cx + r * 0.75f, cy + r * 0.8f)
|
||||
),
|
||||
radius = r + 1,
|
||||
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)
|
||||
}
|
||||
|
||||
// Labels
|
||||
if (state.selectedGeohash.isNotEmpty()) {
|
||||
drawGeohashLabels(
|
||||
state, cx, cy, r, cLat, cLon, colors,
|
||||
labelPaint, haloPaint, labelTypeface, labelTypefaceBold,
|
||||
labelTextSize, labelTextSizeSmall
|
||||
)
|
||||
}
|
||||
|
||||
// Center crosshair
|
||||
val crossAlpha = if (state.isInteracting) 0.9f else pulse
|
||||
val crossColor = colors.accent.copy(alpha = crossAlpha)
|
||||
val gap = 5 * density.density
|
||||
val len = 9 * density.density
|
||||
val strokeW = 1.6f * density.density
|
||||
drawLine(crossColor, Offset(cx - gap - len, cy), Offset(cx - gap, cy), strokeW)
|
||||
drawLine(crossColor, Offset(cx + gap, cy), Offset(cx + gap + len, cy), strokeW)
|
||||
drawLine(crossColor, Offset(cx, cy - gap - len), Offset(cx, cy - gap), strokeW)
|
||||
drawLine(crossColor, Offset(cx, cy + gap), Offset(cx, cy + gap + len), strokeW)
|
||||
drawCircle(crossColor, radius = 1.8f * density.density, center = Offset(cx, cy))
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawGraticule(
|
||||
cx: Float, cy: Float, r: Float, cLat: Double, cLon: Double, color: Color, clip: ClipRect
|
||||
) {
|
||||
val path = Path()
|
||||
val step = 4.0
|
||||
fun strokeSegment(x0: Float, y0: Float, x1: Float, y1: Float) {
|
||||
val seg = clipSegment(x0, y0, x1, y1, clip) ?: return
|
||||
path.moveTo(seg.first.first, seg.first.second)
|
||||
path.lineTo(seg.second.first, seg.second.second)
|
||||
}
|
||||
// latitude lines
|
||||
var lat = -75.0
|
||||
while (lat <= 75.0) {
|
||||
var prev: GlobeMath.Projection? = null
|
||||
var lon = -180.0
|
||||
while (lon <= 180.0) {
|
||||
val p = GlobeMath.project(lat, lon, cLat, cLon)
|
||||
if (p != null && p.cosC > 0.02f) {
|
||||
val pp = prev
|
||||
if (pp != null) strokeSegment(cx + pp.x * r, cy + pp.y * r, cx + p.x * r, cy + p.y * r)
|
||||
prev = p
|
||||
} else prev = null
|
||||
lon += step
|
||||
}
|
||||
lat += 15.0
|
||||
}
|
||||
// longitude lines
|
||||
var lon = -180.0
|
||||
while (lon < 180.0) {
|
||||
var prev: GlobeMath.Projection? = null
|
||||
var la = -90.0
|
||||
while (la <= 90.0) {
|
||||
val p = GlobeMath.project(la, lon, cLat, cLon)
|
||||
if (p != null && p.cosC > 0.02f) {
|
||||
val pp = prev
|
||||
if (pp != null) strokeSegment(cx + pp.x * r, cy + pp.y * r, cx + p.x * r, cy + p.y * r)
|
||||
prev = p
|
||||
} else prev = null
|
||||
la += step
|
||||
}
|
||||
lon += 15.0
|
||||
}
|
||||
drawPath(path, color, style = Stroke(width = 1f))
|
||||
}
|
||||
|
||||
private data class DiscPt(val x: Float, val y: Float, val front: Boolean)
|
||||
|
||||
private fun limbPoint(behind: DiscPt, front: DiscPt): Pair<Float, Float> {
|
||||
var lo = 0f; var hi = 1f
|
||||
repeat(14) {
|
||||
val t = (lo + hi) / 2f
|
||||
val x = behind.x + (front.x - behind.x) * t
|
||||
val y = behind.y + (front.y - behind.y) * t
|
||||
if (x * x + y * y < 1f) lo = t else hi = t
|
||||
}
|
||||
val t = (lo + hi) / 2f
|
||||
return (behind.x + (front.x - behind.x) * t) to (behind.y + (front.y - behind.y) * t)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>, 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: 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() && 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.
|
||||
if (run.isNotEmpty()) {
|
||||
val last = run.last()
|
||||
val dx = cur.x - last.first
|
||||
val dy = cur.y - last.second
|
||||
if (dx * dx + dy * dy > 1.2f) {
|
||||
runs.add(run)
|
||||
run = mutableListOf()
|
||||
}
|
||||
}
|
||||
run.add(cur.x to cur.y)
|
||||
} else {
|
||||
if (run.isNotEmpty() && p != null) {
|
||||
run.add(limbPoint(p, cur))
|
||||
runs.add(run)
|
||||
run = mutableListOf()
|
||||
}
|
||||
}
|
||||
prev = cur
|
||||
}
|
||||
if (run.isNotEmpty()) {
|
||||
if (closed && runs.isNotEmpty() && pts[0].front && pts[n - 1].front) {
|
||||
runs[0] = (run + runs[0]).toMutableList()
|
||||
} else {
|
||||
runs.add(run)
|
||||
}
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
// Skia's edge rasterizer loses precision with path coordinates beyond ~32767px,
|
||||
// which happens at high globe zoom. All geometry is clipped to an expanded
|
||||
// viewport rect in screen space before being handed to a Path.
|
||||
private class ClipRect(val left: Float, val top: Float, val right: Float, val bottom: Float)
|
||||
|
||||
private fun clipPolygon(pts: List<Pair<Float, Float>>, rect: ClipRect): List<Pair<Float, Float>> {
|
||||
fun clipEdge(
|
||||
input: List<Pair<Float, Float>>,
|
||||
inside: (Pair<Float, Float>) -> Boolean,
|
||||
intersect: (Pair<Float, Float>, Pair<Float, Float>) -> Pair<Float, Float>
|
||||
): List<Pair<Float, Float>> {
|
||||
if (input.isEmpty()) return input
|
||||
val result = mutableListOf<Pair<Float, Float>>()
|
||||
var s = input.last()
|
||||
for (e in input) {
|
||||
val eIn = inside(e)
|
||||
val sIn = inside(s)
|
||||
if (eIn) {
|
||||
if (!sIn) result.add(intersect(s, e))
|
||||
result.add(e)
|
||||
} else if (sIn) {
|
||||
result.add(intersect(s, e))
|
||||
}
|
||||
s = e
|
||||
}
|
||||
return result
|
||||
}
|
||||
var out = pts
|
||||
out = clipEdge(out, { it.first >= rect.left }) { a, b ->
|
||||
val t = (rect.left - a.first) / (b.first - a.first)
|
||||
rect.left to (a.second + t * (b.second - a.second))
|
||||
}
|
||||
out = clipEdge(out, { it.first <= rect.right }) { a, b ->
|
||||
val t = (rect.right - a.first) / (b.first - a.first)
|
||||
rect.right to (a.second + t * (b.second - a.second))
|
||||
}
|
||||
out = clipEdge(out, { it.second >= rect.top }) { a, b ->
|
||||
val t = (rect.top - a.second) / (b.second - a.second)
|
||||
(a.first + t * (b.first - a.first)) to rect.top
|
||||
}
|
||||
out = clipEdge(out, { it.second <= rect.bottom }) { a, b ->
|
||||
val t = (rect.bottom - a.second) / (b.second - a.second)
|
||||
(a.first + t * (b.first - a.first)) to rect.bottom
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Liang–Barsky clip of a segment to the rect; returns clipped endpoints or null. */
|
||||
private fun clipSegment(
|
||||
x0: Float, y0: Float, x1: Float, y1: Float, rect: ClipRect
|
||||
): Pair<Pair<Float, Float>, Pair<Float, Float>>? {
|
||||
val dx = x1 - x0
|
||||
val dy = y1 - y0
|
||||
var u1 = 0f
|
||||
var u2 = 1f
|
||||
fun test(p: Float, q: Float): Boolean {
|
||||
if (p == 0f) return q >= 0f
|
||||
val r = q / p
|
||||
if (p < 0f) {
|
||||
if (r > u2) return false
|
||||
if (r > u1) u1 = r
|
||||
} else {
|
||||
if (r < u1) return false
|
||||
if (r < u2) u2 = r
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (!test(-dx, x0 - rect.left)) return null
|
||||
if (!test(dx, rect.right - x0)) return null
|
||||
if (!test(-dy, y0 - rect.top)) return null
|
||||
if (!test(dy, rect.bottom - y0)) return null
|
||||
return ((x0 + u1 * dx) to (y0 + u1 * dy)) to ((x0 + u2 * dx) to (y0 + u2 * dy))
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fill polygon for a projected ring by clamping back-facing points onto the
|
||||
* limb and inserting horizon arc steps between consecutive limb points, so the result
|
||||
* approximates (polygon ∩ visible disc) without self-intersecting chords.
|
||||
*/
|
||||
private fun buildFillPolygon(
|
||||
pts: List<DiscPt>,
|
||||
cx: Float, cy: Float, r: Float
|
||||
): List<Pair<Float, Float>> {
|
||||
val out = ArrayList<Pair<Float, Float>>(pts.size + 128)
|
||||
var prevLimbAngle: Float? = null
|
||||
var firstLimbAngle: Float? = null
|
||||
|
||||
fun addArc(from: Float, to: Float) {
|
||||
var d = to - from
|
||||
while (d > Math.PI) d -= (2 * Math.PI).toFloat()
|
||||
while (d < -Math.PI) d += (2 * Math.PI).toFloat()
|
||||
val steps = (kotlin.math.abs(d) / 0.04f).toInt().coerceIn(1, 64)
|
||||
for (s in 1 until steps) {
|
||||
val a = from + d * s / steps
|
||||
out.add((cx + kotlin.math.cos(a) * r) to (cy + kotlin.math.sin(a) * r))
|
||||
}
|
||||
}
|
||||
|
||||
for (p in pts) {
|
||||
if (p.front) {
|
||||
out.add((cx + p.x * r) to (cy + p.y * r))
|
||||
prevLimbAngle = null
|
||||
} else {
|
||||
val len = kotlin.math.sqrt(p.x * p.x + p.y * p.y)
|
||||
val lx: Float; val ly: Float
|
||||
if (len > 1e-6f) { lx = p.x / len; ly = p.y / len } else { lx = 0f; ly = -1f }
|
||||
val ang = kotlin.math.atan2(ly, lx)
|
||||
prevLimbAngle?.let { addArc(it, ang) }
|
||||
if (firstLimbAngle == null) firstLimbAngle = ang
|
||||
out.add((cx + lx * r) to (cy + ly * r))
|
||||
prevLimbAngle = ang
|
||||
}
|
||||
}
|
||||
// wrap-around arc if the ring ends and starts on the limb
|
||||
val lastAng = prevLimbAngle
|
||||
val firstAng = firstLimbAngle
|
||||
if (lastAng != null && firstAng != null && pts.isNotEmpty() && !pts[0].front) {
|
||||
addArc(lastAng, firstAng)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun DrawScope.fillPolygonClipped(
|
||||
pts: List<DiscPt>,
|
||||
cx: Float, cy: Float, r: Float,
|
||||
color: Color,
|
||||
clip: ClipRect
|
||||
) {
|
||||
if (pts.none { it.front }) return
|
||||
val poly = buildFillPolygon(pts, cx, cy, r)
|
||||
if (poly.size < 3) return
|
||||
val clipped = clipPolygon(poly, clip)
|
||||
if (clipped.size < 3) return
|
||||
val path = Path()
|
||||
path.moveTo(clipped[0].first, clipped[0].second)
|
||||
for (k in 1 until clipped.size) {
|
||||
path.lineTo(clipped[k].first, clipped[k].second)
|
||||
}
|
||||
path.close()
|
||||
drawPath(path, color)
|
||||
}
|
||||
|
||||
private fun DrawScope.strokeRuns(
|
||||
runs: List<MutableList<Pair<Float, Float>>>,
|
||||
cx: Float, cy: Float, r: Float,
|
||||
color: Color,
|
||||
width: Float,
|
||||
clip: ClipRect
|
||||
) {
|
||||
for (run in runs) {
|
||||
if (run.size < 2) continue
|
||||
val path = Path()
|
||||
for (k in 1 until run.size) {
|
||||
val seg = clipSegment(
|
||||
cx + run[k - 1].first * r, cy + run[k - 1].second * r,
|
||||
cx + run[k].first * r, cy + run[k].second * r,
|
||||
clip
|
||||
) ?: continue
|
||||
path.moveTo(seg.first.first, seg.first.second)
|
||||
path.lineTo(seg.second.first, seg.second.second)
|
||||
}
|
||||
drawPath(path, color, style = Stroke(width = width))
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
cx: Float, cy: Float, r: Float,
|
||||
cLat: Double, cLon: Double,
|
||||
colors: GlobeColors,
|
||||
clip: ClipRect
|
||||
) {
|
||||
val n = ring.size
|
||||
if (n < 3 || n * 3 > scratch.size) return
|
||||
|
||||
var anyFront = false
|
||||
var i = 0
|
||||
while (i < n) {
|
||||
val lat = ring.coords[i * 2].toDouble()
|
||||
val lon = ring.coords[i * 2 + 1].toDouble()
|
||||
val p = GlobeMath.projectRaw(lat, lon, cLat, cLon)
|
||||
scratch[i * 3] = p.x
|
||||
scratch[i * 3 + 1] = p.y
|
||||
scratch[i * 3 + 2] = p.cosC
|
||||
if (p.cosC >= 0f) anyFront = true
|
||||
i++
|
||||
}
|
||||
if (!anyFront) return
|
||||
|
||||
val pts = ArrayList<DiscPt>(n)
|
||||
i = 0
|
||||
while (i < n) {
|
||||
pts.add(DiscPt(scratch[i * 3], scratch[i * 3 + 1], scratch[i * 3 + 2] > 0.005f))
|
||||
i++
|
||||
}
|
||||
val runs = buildFrontRuns(pts)
|
||||
fillPolygonClipped(pts, cx, cy, r, colors.land, clip)
|
||||
strokeRuns(runs, cx, cy, r, colors.coastline, 1.4f, clip)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawGeohashGrid(
|
||||
state: GlobeState,
|
||||
cx: Float, cy: Float, r: Float,
|
||||
cLat: Double, cLon: Double,
|
||||
colors: GlobeColors,
|
||||
clip: ClipRect
|
||||
) {
|
||||
val selected = state.selectedGeohash
|
||||
val cells = linkedSetOf(selected)
|
||||
cells.addAll(Geohash.neighborsSamePrecision(selected))
|
||||
|
||||
for (cell in cells) {
|
||||
val isSelected = cell == selected
|
||||
val b = Geohash.decodeToBounds(cell)
|
||||
val spanLat = b.latMax - b.latMin
|
||||
val spanLon = b.lonMax - b.lonMin
|
||||
val steps = ceil(spanLat / 1.5).toInt().coerceIn(4, 48)
|
||||
|
||||
// Sample the cell boundary: N edge l->r, E edge t->b, S edge r->l, W edge b->t
|
||||
val pts = ArrayList<Triple<Float, Float, Boolean>>(steps * 4 + 4)
|
||||
fun addPt(lat: Double, lonRaw: Double) {
|
||||
var lon = lonRaw
|
||||
// keep boundary continuous across the antimeridian relative to the view
|
||||
val ref = cLon
|
||||
while (lon - ref > 180.0) lon -= 360.0
|
||||
while (lon - ref < -180.0) lon += 360.0
|
||||
val p = GlobeMath.projectRaw(lat, lon, cLat, cLon)
|
||||
pts.add(Triple(p.x, p.y, p.cosC >= 0.005f))
|
||||
}
|
||||
for (s in 0..steps) {
|
||||
val t = s.toDouble() / steps
|
||||
addPt(b.latMax, b.lonMin + spanLon * t)
|
||||
}
|
||||
for (s in 1..steps) {
|
||||
val t = s.toDouble() / steps
|
||||
addPt(b.latMax - spanLat * t, b.lonMax)
|
||||
}
|
||||
for (s in 1..steps) {
|
||||
val t = s.toDouble() / steps
|
||||
addPt(b.latMin, b.lonMax - spanLon * t)
|
||||
}
|
||||
for (s in 1 until steps) {
|
||||
val t = s.toDouble() / steps
|
||||
addPt(b.latMin + spanLat * t, b.lonMin)
|
||||
}
|
||||
|
||||
if (pts.none { it.third }) continue
|
||||
|
||||
val discPts = pts.map { DiscPt(it.first, it.second, it.third) }
|
||||
val runs = buildFrontRuns(discPts)
|
||||
|
||||
if (isSelected) {
|
||||
fillPolygonClipped(discPts, cx, cy, r, colors.accent.copy(alpha = 0.20f), clip)
|
||||
strokeRuns(runs, cx, cy, r, colors.accent.copy(alpha = 0.35f), 7f, clip)
|
||||
strokeRuns(runs, cx, cy, r, colors.accent, 3.2f, clip)
|
||||
} else {
|
||||
fillPolygonClipped(discPts, cx, cy, r, colors.grid.copy(alpha = 0.05f), clip)
|
||||
strokeRuns(runs, cx, cy, r, colors.grid, 1.6f, clip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawGeohashLabels(
|
||||
state: GlobeState,
|
||||
cx: Float, cy: Float, r: Float,
|
||||
cLat: Double, cLon: Double,
|
||||
colors: GlobeColors,
|
||||
labelPaint: Paint,
|
||||
haloPaint: Paint,
|
||||
labelTypeface: Typeface?,
|
||||
labelTypefaceBold: Typeface?,
|
||||
selectedSize: Float,
|
||||
neighborSize: Float
|
||||
) {
|
||||
val selected = state.selectedGeohash
|
||||
val cells = linkedSetOf(selected)
|
||||
cells.addAll(Geohash.neighborsSamePrecision(selected))
|
||||
val canvas = drawContext.canvas.nativeCanvas
|
||||
|
||||
for (cell in cells) {
|
||||
val isSelected = cell == selected
|
||||
val (lat, lon) = Geohash.decodeToCenter(cell)
|
||||
val p = GlobeMath.project(lat, lon, cLat, cLon) ?: continue
|
||||
if (p.cosC < 0.08f) continue
|
||||
val sx = cx + p.x * r
|
||||
val sy = cy + p.y * r
|
||||
val paint = labelPaint
|
||||
paint.textSize = if (isSelected) selectedSize else neighborSize
|
||||
paint.typeface = if (isSelected) (labelTypefaceBold ?: labelTypeface) else labelTypeface
|
||||
paint.color = if (isSelected) {
|
||||
android.graphics.Color.argb(255, (colors.accent.red * 255).toInt(), (colors.accent.green * 255).toInt(), (colors.accent.blue * 255).toInt())
|
||||
} else {
|
||||
android.graphics.Color.argb(
|
||||
(160 * p.cosC.coerceIn(0.4f, 1f)).toInt(),
|
||||
(colors.label.red * 255).toInt(), (colors.label.green * 255).toInt(), (colors.label.blue * 255).toInt()
|
||||
)
|
||||
}
|
||||
val baseline = sy - ((paint.descent() + paint.ascent()) / 2f)
|
||||
haloPaint.textSize = paint.textSize
|
||||
haloPaint.typeface = paint.typeface
|
||||
haloPaint.strokeWidth = paint.textSize * 0.18f
|
||||
haloPaint.color = android.graphics.Color.argb(
|
||||
if (isSelected) 200 else 120,
|
||||
(colors.labelHalo.red * 255).toInt(), (colors.labelHalo.green * 255).toInt(), (colors.labelHalo.blue * 255).toInt()
|
||||
)
|
||||
canvas.drawText(cell, sx, baseline, haloPaint)
|
||||
canvas.drawText(cell, sx, baseline, paint)
|
||||
}
|
||||
}
|
||||
130
app/src/main/java/com/bitchat/android/ui/globe/LandData.kt
Normal file
130
app/src/main/java/com/bitchat/android/ui/globe/LandData.kt
Normal file
@ -0,0 +1,130 @@
|
||||
package com.bitchat.android.ui.globe
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Loads the bundled Natural Earth 110m land polygons (public domain) from assets
|
||||
* and exposes them as flat rings of lat/lon pairs for vector globe rendering.
|
||||
*/
|
||||
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 }
|
||||
synchronized(this) {
|
||||
cached?.let { return it }
|
||||
val rings = mutableListOf<Ring>()
|
||||
val text = context.assets.open("world_land.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")) {
|
||||
"Polygon" -> parsePolygon(geom.getJSONArray("coordinates"), rings)
|
||||
"MultiPolygon" -> {
|
||||
val polys = geom.getJSONArray("coordinates")
|
||||
for (j in 0 until polys.length()) {
|
||||
parsePolygon(polys.getJSONArray(j), rings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cached = rings
|
||||
return rings
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
val n = ringJson.length()
|
||||
if (n < 3) continue
|
||||
val coords = FloatArray(n * 2)
|
||||
for (p in 0 until n) {
|
||||
val pt = ringJson.getJSONArray(p)
|
||||
coords[p * 2] = pt.getDouble(1).toFloat() // lat
|
||||
coords[p * 2 + 1] = pt.getDouble(0).toFloat() // lon
|
||||
}
|
||||
out.add(Ring(coords, n))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -536,7 +536,7 @@
|
||||
<string name="channel_count_prefix"> · ⧉ </string>
|
||||
<string name="nobody_around">Nobody around…</string>
|
||||
<string name="you_suffix"> (you)</string>
|
||||
<string name="pan_zoom_instruction">Pan and zoom to select a geohash</string>
|
||||
<string name="pan_zoom_instruction">Drag to spin · Pinch to zoom · Tap to focus</string>
|
||||
<string name="select">Select</string>
|
||||
<string name="type_a_message_placeholder">Type a message…</string>
|
||||
<string name="mention_suggestion_at">@%1$s</string>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user