Add geohash globe render quality selector

This commit is contained in:
a1denvalu3 2026-07-30 14:39:29 +02:00
parent 5bde4e0b4a
commit 8602db88de
5 changed files with 228 additions and 23 deletions

View File

@ -15,6 +15,9 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
@ -34,6 +37,8 @@ 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.GlobeRenderQuality
import com.bitchat.android.ui.globe.GlobeRenderQualityPreference
import com.bitchat.android.ui.globe.GlobeState
import com.bitchat.android.ui.globe.GlobeView
import com.bitchat.android.ui.globe.LandData
@ -152,6 +157,9 @@ class GeohashPickerActivity : OrientationAwareActivity() {
val labelTypeface = remember { ResourcesCompat.getFont(context, R.font.geist_mono_medium) }
val labelTypefaceBold = remember { ResourcesCompat.getFont(context, R.font.geist_mono_semibold) }
var renderQuality by remember {
mutableStateOf(GlobeRenderQualityPreference.load(context))
}
Box(
Modifier
@ -165,6 +173,7 @@ class GeohashPickerActivity : OrientationAwareActivity() {
land = rings,
borders = borders,
cities = cities,
renderQuality = renderQuality,
labelTypeface = labelTypeface,
labelTypefaceBold = labelTypefaceBold,
modifier = Modifier.fillMaxSize()
@ -183,15 +192,45 @@ class GeohashPickerActivity : OrientationAwareActivity() {
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,
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp)
)
) {
Text(
text = stringResource(R.string.pan_zoom_instruction),
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(8.dp))
val qualities = GlobeRenderQuality.entries
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth()
) {
qualities.forEachIndexed { index, quality ->
SegmentedButton(
selected = renderQuality == quality,
onClick = {
renderQuality = quality
GlobeRenderQualityPreference.save(context, quality)
},
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = qualities.size
),
label = {
Text(
text = stringResource(quality.labelResource),
fontSize = 11.sp,
fontFamily = BitchatFontFamily
)
}
)
}
}
}
}
// Floating bottom controls
@ -290,6 +329,13 @@ class GeohashPickerActivity : OrientationAwareActivity() {
}
}
private val GlobeRenderQuality.labelResource: Int
get() = when (this) {
GlobeRenderQuality.FAST -> R.string.globe_render_quality_fast
GlobeRenderQuality.MEDIUM -> R.string.globe_render_quality_medium
GlobeRenderQuality.HIGH -> R.string.globe_render_quality_high
}
private fun levelForLength(length: Int): GeohashChannelLevel {
return when (length) {
in 0..2 -> GeohashChannelLevel.REGION

View File

@ -0,0 +1,33 @@
package com.bitchat.android.ui.globe
import android.content.Context
enum class GlobeRenderQuality {
FAST,
MEDIUM,
HIGH;
companion object {
fun fromStoredValue(value: String?): GlobeRenderQuality =
entries.firstOrNull { it.name == value } ?: MEDIUM
}
}
object GlobeRenderQualityPreference {
private const val PREFERENCES_NAME = "bitchat_settings"
private const val KEY_RENDER_QUALITY = "geohash_globe_render_quality"
fun load(context: Context): GlobeRenderQuality {
val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
return GlobeRenderQuality.fromStoredValue(
preferences.getString(KEY_RENDER_QUALITY, GlobeRenderQuality.MEDIUM.name)
)
}
fun save(context: Context, quality: GlobeRenderQuality) {
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_RENDER_QUALITY, quality.name)
.apply()
}
}

View File

@ -64,6 +64,54 @@ data class GlobeColors(
private class Star(val x: Float, val y: Float, val radius: Float, val alpha: Float)
internal data class GlobeFrameDetail(
val graticuleStepDegrees: Double,
val landPointStride: Int,
val showBorders: Boolean,
val cityMaxRank: Int?,
val showCityLabels: Boolean,
val showGeohashGrid: Boolean,
val showNeighborCells: Boolean
)
internal fun globeFrameDetail(
quality: GlobeRenderQuality,
isMoving: Boolean
): GlobeFrameDetail {
if (!isMoving || quality == GlobeRenderQuality.HIGH) {
return GlobeFrameDetail(
graticuleStepDegrees = 4.0,
landPointStride = 1,
showBorders = true,
cityMaxRank = null,
showCityLabels = true,
showGeohashGrid = true,
showNeighborCells = true
)
}
return when (quality) {
GlobeRenderQuality.FAST -> GlobeFrameDetail(
graticuleStepDegrees = 10.0,
landPointStride = 2,
showBorders = false,
cityMaxRank = -1,
showCityLabels = false,
showGeohashGrid = false,
showNeighborCells = false
)
GlobeRenderQuality.MEDIUM -> GlobeFrameDetail(
graticuleStepDegrees = 8.0,
landPointStride = 2,
showBorders = true,
cityMaxRank = 1,
showCityLabels = false,
showGeohashGrid = true,
showNeighborCells = false
)
GlobeRenderQuality.HIGH -> error("Handled above")
}
}
@Composable
fun GlobeView(
state: GlobeState,
@ -71,6 +119,7 @@ fun GlobeView(
land: List<LandData.Ring>,
borders: List<LandData.Ring>,
cities: List<LandData.City>,
renderQuality: GlobeRenderQuality,
labelTypeface: Typeface?,
labelTypefaceBold: Typeface?,
modifier: Modifier = Modifier
@ -275,12 +324,12 @@ fun GlobeView(
val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f)
val lowDetail = state.isInMotion
val frameDetail = globeFrameDetail(renderQuality, state.isInMotion)
// Graticule
drawGraticule(
cx, cy, r, cLat, cLon, colors.graticule, clip,
step = if (lowDetail) 10.0 else 4.0
step = frameDetail.graticuleStepDegrees
)
// Landmasses
@ -294,12 +343,12 @@ fun GlobeView(
r = r,
colors = colors,
clip = clip,
pointStride = if (lowDetail && ring.size >= 64) 2 else 1
pointStride = if (ring.size >= 64) frameDetail.landPointStride else 1
)
}
// Country borders are restored when interaction settles.
if (!lowDetail) {
if (frameDetail.showBorders) {
for (line in borders) {
drawBorderLine(line, borderScratch, preparedProjector, cx, cy, r, colors, clip)
}
@ -331,20 +380,27 @@ fun GlobeView(
)
// Cities are detail-only; omitting them while moving keeps touch latency predictable.
if (!lowDetail) {
val cityMaxRank = frameDetail.cityMaxRank
if (cityMaxRank == null || cityMaxRank >= 0) {
drawCities(
cities, state, preparedProjector, cx, cy, r, colors,
labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density
labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density,
maxRankOverride = cityMaxRank,
showLabels = frameDetail.showCityLabels
)
}
// Detailed cells and labels settle into place after the gesture ends.
if (!lowDetail && state.selectedGeohash.isNotEmpty()) {
drawGeohashGrid(state, cx, cy, r, cLat, cLon, colors, clip)
if (frameDetail.showGeohashGrid && state.selectedGeohash.isNotEmpty()) {
drawGeohashGrid(
state, cx, cy, r, cLat, cLon, colors, clip,
includeNeighbors = frameDetail.showNeighborCells
)
drawGeohashLabels(
state, cx, cy, r, cLat, cLon, colors,
labelPaint, haloPaint, labelTypeface, labelTypefaceBold,
labelTextSize, labelTextSizeSmall
labelTextSize, labelTextSizeSmall,
includeNeighbors = frameDetail.showNeighborCells
)
}
@ -697,12 +753,14 @@ private fun DrawScope.drawCities(
haloPaint: Paint,
typeface: Typeface?,
textSize: Float,
density: Float
density: Float,
maxRankOverride: Int?,
showLabels: Boolean
) {
if (cities.isEmpty()) return
val projection = FloatArray(3)
val zoom = state.zoom
val maxRank = when {
val maxRank = maxRankOverride ?: when {
zoom < 2f -> 1
zoom < 8f -> 3
zoom < 40f -> 4
@ -725,7 +783,7 @@ private fun DrawScope.drawCities(
else colors.label.copy(alpha = alpha * 0.85f)
drawCircle(dotColor, radius = dotRadius, center = Offset(sx, sy))
if (zoom >= 6f || (important && zoom >= 2.5f)) {
if (showLabels && (zoom >= 6f || (important && zoom >= 2.5f))) {
labelPaint.textSize = textSize
labelPaint.typeface = typeface
labelPaint.textAlign = Paint.Align.LEFT
@ -789,11 +847,12 @@ private fun DrawScope.drawGeohashGrid(
cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors,
clip: ClipRect
clip: ClipRect,
includeNeighbors: Boolean
) {
val selected = state.selectedGeohash
val cells = linkedSetOf(selected)
cells.addAll(Geohash.neighborsSamePrecision(selected))
if (includeNeighbors) cells.addAll(Geohash.neighborsSamePrecision(selected))
for (cell in cells) {
val isSelected = cell == selected
@ -856,11 +915,12 @@ private fun DrawScope.drawGeohashLabels(
labelTypeface: Typeface?,
labelTypefaceBold: Typeface?,
selectedSize: Float,
neighborSize: Float
neighborSize: Float,
includeNeighbors: Boolean
) {
val selected = state.selectedGeohash
val cells = linkedSetOf(selected)
cells.addAll(Geohash.neighborsSamePrecision(selected))
if (includeNeighbors) cells.addAll(Geohash.neighborsSamePrecision(selected))
val canvas = drawContext.canvas.nativeCanvas
for (cell in cells) {

View File

@ -540,6 +540,9 @@
<string name="nobody_around">Nobody around…</string>
<string name="you_suffix"> (you)</string>
<string name="pan_zoom_instruction">Drag to spin · Pinch to zoom · Tap to focus</string>
<string name="globe_render_quality_fast">Fast</string>
<string name="globe_render_quality_medium">Medium</string>
<string name="globe_render_quality_high">High</string>
<string name="select">Select</string>
<string name="type_a_message_placeholder">Type a message…</string>
<string name="mention_suggestion_at">@%1$s</string>

View File

@ -0,0 +1,63 @@
package com.bitchat.android.ui.globe
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GlobeRenderQualityTest {
@Test
fun invalidStoredValue_defaultsToMedium() {
assertEquals(GlobeRenderQuality.MEDIUM, GlobeRenderQuality.fromStoredValue(null))
assertEquals(GlobeRenderQuality.MEDIUM, GlobeRenderQuality.fromStoredValue("UNKNOWN"))
}
@Test
fun stationaryFrame_alwaysUsesFullDetail() {
GlobeRenderQuality.entries.forEach { quality ->
val detail = globeFrameDetail(quality, isMoving = false)
assertEquals(1, detail.landPointStride)
assertTrue(detail.showBorders)
assertNull(detail.cityMaxRank)
assertTrue(detail.showCityLabels)
assertTrue(detail.showGeohashGrid)
assertTrue(detail.showNeighborCells)
}
}
@Test
fun movingFastFrame_usesMinimumDetail() {
val detail = globeFrameDetail(GlobeRenderQuality.FAST, isMoving = true)
assertEquals(2, detail.landPointStride)
assertFalse(detail.showBorders)
assertEquals(-1, detail.cityMaxRank)
assertFalse(detail.showGeohashGrid)
}
@Test
fun movingMediumFrame_preservesOrientationAndSelection() {
val detail = globeFrameDetail(GlobeRenderQuality.MEDIUM, isMoving = true)
assertEquals(2, detail.landPointStride)
assertTrue(detail.showBorders)
assertEquals(1, detail.cityMaxRank)
assertFalse(detail.showCityLabels)
assertTrue(detail.showGeohashGrid)
assertFalse(detail.showNeighborCells)
}
@Test
fun movingHighFrame_usesFullDetail() {
val detail = globeFrameDetail(GlobeRenderQuality.HIGH, isMoving = true)
assertEquals(1, detail.landPointStride)
assertTrue(detail.showBorders)
assertNull(detail.cityMaxRank)
assertTrue(detail.showCityLabels)
assertTrue(detail.showNeighborCells)
}
}