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.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.* 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.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.ui.globe.GlobeColors 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.GlobeState
import com.bitchat.android.ui.globe.GlobeView import com.bitchat.android.ui.globe.GlobeView
import com.bitchat.android.ui.globe.LandData 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 labelTypeface = remember { ResourcesCompat.getFont(context, R.font.geist_mono_medium) }
val labelTypefaceBold = remember { ResourcesCompat.getFont(context, R.font.geist_mono_semibold) } val labelTypefaceBold = remember { ResourcesCompat.getFont(context, R.font.geist_mono_semibold) }
var renderQuality by remember {
mutableStateOf(GlobeRenderQualityPreference.load(context))
}
Box( Box(
Modifier Modifier
@ -165,6 +173,7 @@ class GeohashPickerActivity : OrientationAwareActivity() {
land = rings, land = rings,
borders = borders, borders = borders,
cities = cities, cities = cities,
renderQuality = renderQuality,
labelTypeface = labelTypeface, labelTypeface = labelTypeface,
labelTypefaceBold = labelTypefaceBold, labelTypefaceBold = labelTypefaceBold,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@ -183,15 +192,45 @@ class GeohashPickerActivity : OrientationAwareActivity() {
tonalElevation = 3.dp, tonalElevation = 3.dp,
shadowElevation = 6.dp shadowElevation = 6.dp
) { ) {
Text( Column(
text = stringResource(R.string.pan_zoom_instruction), horizontalAlignment = Alignment.CenterHorizontally,
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp) .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 // 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 { private fun levelForLength(length: Int): GeohashChannelLevel {
return when (length) { return when (length) {
in 0..2 -> GeohashChannelLevel.REGION 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) 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 @Composable
fun GlobeView( fun GlobeView(
state: GlobeState, state: GlobeState,
@ -71,6 +119,7 @@ fun GlobeView(
land: List<LandData.Ring>, land: List<LandData.Ring>,
borders: List<LandData.Ring>, borders: List<LandData.Ring>,
cities: List<LandData.City>, cities: List<LandData.City>,
renderQuality: GlobeRenderQuality,
labelTypeface: Typeface?, labelTypeface: Typeface?,
labelTypefaceBold: Typeface?, labelTypefaceBold: Typeface?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -275,12 +324,12 @@ fun GlobeView(
val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f) val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f)
val lowDetail = state.isInMotion val frameDetail = globeFrameDetail(renderQuality, state.isInMotion)
// Graticule // Graticule
drawGraticule( drawGraticule(
cx, cy, r, cLat, cLon, colors.graticule, clip, cx, cy, r, cLat, cLon, colors.graticule, clip,
step = if (lowDetail) 10.0 else 4.0 step = frameDetail.graticuleStepDegrees
) )
// Landmasses // Landmasses
@ -294,12 +343,12 @@ fun GlobeView(
r = r, r = r,
colors = colors, colors = colors,
clip = clip, 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. // Country borders are restored when interaction settles.
if (!lowDetail) { if (frameDetail.showBorders) {
for (line in borders) { for (line in borders) {
drawBorderLine(line, borderScratch, preparedProjector, cx, cy, r, colors, clip) 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. // Cities are detail-only; omitting them while moving keeps touch latency predictable.
if (!lowDetail) { val cityMaxRank = frameDetail.cityMaxRank
if (cityMaxRank == null || cityMaxRank >= 0) {
drawCities( drawCities(
cities, state, preparedProjector, cx, cy, r, colors, 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. // Detailed cells and labels settle into place after the gesture ends.
if (!lowDetail && state.selectedGeohash.isNotEmpty()) { if (frameDetail.showGeohashGrid && state.selectedGeohash.isNotEmpty()) {
drawGeohashGrid(state, cx, cy, r, cLat, cLon, colors, clip) drawGeohashGrid(
state, cx, cy, r, cLat, cLon, colors, clip,
includeNeighbors = frameDetail.showNeighborCells
)
drawGeohashLabels( drawGeohashLabels(
state, cx, cy, r, cLat, cLon, colors, state, cx, cy, r, cLat, cLon, colors,
labelPaint, haloPaint, labelTypeface, labelTypefaceBold, labelPaint, haloPaint, labelTypeface, labelTypefaceBold,
labelTextSize, labelTextSizeSmall labelTextSize, labelTextSizeSmall,
includeNeighbors = frameDetail.showNeighborCells
) )
} }
@ -697,12 +753,14 @@ private fun DrawScope.drawCities(
haloPaint: Paint, haloPaint: Paint,
typeface: Typeface?, typeface: Typeface?,
textSize: Float, textSize: Float,
density: Float density: Float,
maxRankOverride: Int?,
showLabels: Boolean
) { ) {
if (cities.isEmpty()) return if (cities.isEmpty()) return
val projection = FloatArray(3) val projection = FloatArray(3)
val zoom = state.zoom val zoom = state.zoom
val maxRank = when { val maxRank = maxRankOverride ?: when {
zoom < 2f -> 1 zoom < 2f -> 1
zoom < 8f -> 3 zoom < 8f -> 3
zoom < 40f -> 4 zoom < 40f -> 4
@ -725,7 +783,7 @@ private fun DrawScope.drawCities(
else colors.label.copy(alpha = alpha * 0.85f) else colors.label.copy(alpha = alpha * 0.85f)
drawCircle(dotColor, radius = dotRadius, center = Offset(sx, sy)) 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.textSize = textSize
labelPaint.typeface = typeface labelPaint.typeface = typeface
labelPaint.textAlign = Paint.Align.LEFT labelPaint.textAlign = Paint.Align.LEFT
@ -789,11 +847,12 @@ private fun DrawScope.drawGeohashGrid(
cx: Float, cy: Float, r: Float, cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double, cLat: Double, cLon: Double,
colors: GlobeColors, colors: GlobeColors,
clip: ClipRect clip: ClipRect,
includeNeighbors: Boolean
) { ) {
val selected = state.selectedGeohash val selected = state.selectedGeohash
val cells = linkedSetOf(selected) val cells = linkedSetOf(selected)
cells.addAll(Geohash.neighborsSamePrecision(selected)) if (includeNeighbors) cells.addAll(Geohash.neighborsSamePrecision(selected))
for (cell in cells) { for (cell in cells) {
val isSelected = cell == selected val isSelected = cell == selected
@ -856,11 +915,12 @@ private fun DrawScope.drawGeohashLabels(
labelTypeface: Typeface?, labelTypeface: Typeface?,
labelTypefaceBold: Typeface?, labelTypefaceBold: Typeface?,
selectedSize: Float, selectedSize: Float,
neighborSize: Float neighborSize: Float,
includeNeighbors: Boolean
) { ) {
val selected = state.selectedGeohash val selected = state.selectedGeohash
val cells = linkedSetOf(selected) val cells = linkedSetOf(selected)
cells.addAll(Geohash.neighborsSamePrecision(selected)) if (includeNeighbors) cells.addAll(Geohash.neighborsSamePrecision(selected))
val canvas = drawContext.canvas.nativeCanvas val canvas = drawContext.canvas.nativeCanvas
for (cell in cells) { for (cell in cells) {

View File

@ -540,6 +540,9 @@
<string name="nobody_around">Nobody around…</string> <string name="nobody_around">Nobody around…</string>
<string name="you_suffix"> (you)</string> <string name="you_suffix"> (you)</string>
<string name="pan_zoom_instruction">Drag to spin · Pinch to zoom · Tap to focus</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="select">Select</string>
<string name="type_a_message_placeholder">Type a message…</string> <string name="type_a_message_placeholder">Type a message…</string>
<string name="mention_suggestion_at">@%1$s</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)
}
}