Compare commits

...

7 commits

Author SHA1 Message Date
f089879d1b MERCATE LOOOOOOOOOOOOOOOOOOOOOON 2025-09-02 15:39:25 +03:00
7a83286681 more ViewModel-ing 2025-09-02 13:34:52 +03:00
2937f17a69 Set location dialog prefab and proper ViewModel handling 2025-09-02 13:31:35 +03:00
8bdbe3dc69 Dropdown menu and almost proper debugging 2025-09-01 12:50:36 +03:00
6c69440b4d cross 2025-08-25 15:56:11 +03:00
3f2b3eec45 little refactoring 2025-08-25 15:41:31 +03:00
8a530d9d48 mercator 2025-08-25 15:15:51 +03:00
7 changed files with 288 additions and 63 deletions

View file

@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-08T10:50:02.571721922Z"> <DropdownSelection timestamp="2025-08-26T11:06:03.805342749Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/secondbeam/.android/avd/Pixel_3.avd" /> <DeviceId pluginId="LocalEmulator" identifier="path=/home/secondbeam/.config/.android/avd/Virtual_Pixel_3.avd" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

View file

@ -3,33 +3,47 @@ package com.mirenkov.ktheightmap
import android.app.Application import android.app.Application
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Build
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.toUpperCase
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@ -72,7 +86,7 @@ class MainActivity : ComponentActivity() {
@Composable @Composable
fun Main(vm: TileViewModel = viewModel()) { fun Main(vm: TileViewModel = viewModel()) {
val sliderValue = rememberSaveable { mutableFloatStateOf(1F) } var scale by rememberSaveable { vm.scale }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val tileContainer = TileContainer(vm, coroutineScope) val tileContainer = TileContainer(vm, coroutineScope)
KtHeightMapTheme { KtHeightMapTheme {
@ -81,14 +95,14 @@ fun Main(vm: TileViewModel = viewModel()) {
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
.safeDrawingPadding() .safeDrawingPadding()
.align(Alignment.Center), .align(Alignment.Center),
floatingActionButton = { ToolButton() } floatingActionButton = { ToolButton(vm) }
) { innerPadding -> ) { innerPadding ->
MapCanvas( MapCanvas(
viewModel = vm,
gridColor = colorScheme.primary, gridColor = colorScheme.primary,
backColor = colorScheme.background, backColor = colorScheme.background,
scale = sliderValue,
tileContainer = tileContainer, tileContainer = tileContainer,
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding)
) )
} }
Box(modifier = Modifier.safeDrawingPadding() Box(modifier = Modifier.safeDrawingPadding()
@ -98,8 +112,8 @@ fun Main(vm: TileViewModel = viewModel()) {
.offset(0.dp, 60.dp) .offset(0.dp, 60.dp)
) { ) {
Slider( Slider(
value = sliderValue.floatValue, value = scale,
onValueChange = { sliderValue.floatValue = it }, onValueChange = { scale = it },
valueRange = 1F..14F, valueRange = 1F..14F,
modifier = Modifier.align(Alignment.CenterStart) modifier = Modifier.align(Alignment.CenterStart)
) )
@ -109,33 +123,97 @@ fun Main(vm: TileViewModel = viewModel()) {
} }
@Composable @Composable
fun ToolButton() { fun ToolButton(viewModel: TileViewModel) {
val context = LocalContext.current val context = LocalContext.current
var expanded by rememberSaveable{ mutableStateOf(false) }
var debug by rememberSaveable{ viewModel.debug }
var logRequested by remember{ viewModel.logRequested }
val dialogShown = rememberSaveable{ mutableStateOf(false) }
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
expanded = !expanded
}
) {
Icon(Icons.Filled.Build, contentDescription = "Tools")
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("Toggle debug") },
onClick = {
debug = !debug
expanded = false
}
)
DropdownMenuItem(
text = { Text("Log") },
onClick = {
logRequested = true
expanded = false
}
)
DropdownMenuItem(
text = { Text("Set location") },
onClick = {
dialogShown.value = true
}
)
DropdownMenuItem(
text = { Text("Settings") },
onClick = {
expanded = false
val intent = Intent(context, SettingsActivity::class.java) val intent = Intent(context, SettingsActivity::class.java)
context.startActivity(intent) context.startActivity(intent)
} }
) { Icon(Icons.Filled.Build, contentDescription = "Tools") } )
SetLocationDialog(viewModel, dialogShown)
}
}
} }
@Preview(showBackground = true)
@Composable @Composable
fun MainPreview() { fun SetLocationDialog(vm: TileViewModel, dialogShown: MutableState<Boolean>) {
KtHeightMapTheme { var showLocationDialog by dialogShown
Scaffold( if (showLocationDialog) {
modifier = Modifier.safeDrawingPadding(), var latitudeText by remember { vm.latitudeText }
floatingActionButton = { ToolButton() } var longitudeText by remember { vm.longitudeText }
) { innerPadding -> AlertDialog (
Box( onDismissRequest = { showLocationDialog = false },
modifier = Modifier.padding(innerPadding) title = { Text("Input coordinates") },
.fillMaxSize(), text = {
contentAlignment = Alignment.Center Column {
) { TextField(
Text( value = latitudeText,
text = "Gryadki" onValueChange = { newValue ->
latitudeText = newValue
},
label = { Text("Latitude") },
placeholder = { Text("60.086763") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
TextField(
value = longitudeText,
onValueChange = { newValue ->
longitudeText = newValue
},
label = { Text("Longitude") },
placeholder = { Text("30.014658") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
) )
} }
} },
confirmButton = { TextButton(onClick = {
val latitude = latitudeText.text.toDoubleOrNull() ?: 0.0
val longitude = longitudeText.text.toDoubleOrNull() ?: 0.0
Log.i(TAG, "Lat: %.6f, Lon: %.6f".format(latitude, longitude))
Log.i(TAG, "X = %.6f".format(SphereMercator.mercateLon(longitude, vm.scale.floatValue.toInt(), -TILE_SIZE.toDouble())))
}) {
Text("Confirm".uppercase())
} },
dismissButton = { TextButton(onClick = { showLocationDialog = false }) {
Text("Cancel".uppercase())
} }
)
} }
} }

View file

@ -1,16 +1,21 @@
package com.mirenkov.ktheightmap package com.mirenkov.ktheightmap
import android.util.Log
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableFloatState import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@ -25,52 +30,71 @@ import kotlin.math.absoluteValue
@Composable @Composable
fun MapCanvas( fun MapCanvas(
viewModel: TileViewModel,
backColor: Color, backColor: Color,
gridColor: Color, gridColor: Color,
scale: MutableFloatState,
tileContainer: TileContainer, tileContainer: TileContainer,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val offsetX = rememberSaveable { mutableFloatStateOf(-TILE_SIZE) } var offsetX by rememberSaveable { viewModel.mapOffsetX }
val offsetY = rememberSaveable { mutableFloatStateOf(-TILE_SIZE) } var offsetY by rememberSaveable { viewModel.mapOffsetY }
val scale by rememberSaveable { viewModel.scale }
val textMeasurer = rememberTextMeasurer() val textMeasurer = rememberTextMeasurer()
val debug by rememberSaveable { viewModel.debug }
var logRequested by rememberSaveable { viewModel.logRequested }
Canvas( Canvas(
modifier = modifier.fillMaxSize() modifier = modifier.fillMaxSize()
.pointerInput(Unit) { .pointerInput(Unit) {
detectDragGestures { _, distance -> detectDragGestures { _, distance ->
offsetX.floatValue -= distance.x offsetX -= distance.x
offsetY.floatValue -= distance.y offsetY -= distance.y
} }
} }
) { ) {
if (viewModel.halvedOffsetX == null) {
viewModel.halvedOffsetX = size.width / 2
viewModel.halvedOffsetY = size.height / 2
}
val halvedX = viewModel.halvedOffsetX!!
val halvedY = viewModel.halvedOffsetY!!
drawRect( drawRect(
color = backColor, color = backColor,
size = size size = size
) )
if (logRequested) {
Log.i(TAG, "Offset: %.6f, %.6f".format(offsetX, offsetY))
logRequested = false
}
val oldLevel = tileContainer.getLevel() val oldLevel = tileContainer.getLevel()
val level = scale.floatValue.toInt() val level = scale.toInt()
val levelDiff = level - oldLevel val levelDiff = level - oldLevel
if (levelDiff < 0) { if (levelDiff < 0) {
repeat (levelDiff.absoluteValue) { repeat (levelDiff.absoluteValue) {
offsetX.floatValue -= size.width / 2F + TILE_SIZE offsetX -= halvedX + TILE_SIZE
offsetY.floatValue -= size.height / 2F + TILE_SIZE offsetY -= halvedY + TILE_SIZE
offsetX.floatValue /= 2F offsetX /= 2F
offsetY.floatValue /= 2F offsetY /= 2F
} }
} else if (levelDiff > 0) { } else if (levelDiff > 0) {
repeat (levelDiff) { repeat (levelDiff) {
offsetX.floatValue *= 2F offsetX *= 2F
offsetY.floatValue *= 2F offsetY *= 2F
offsetX.floatValue += size.width / 2F + TILE_SIZE offsetX += halvedX + TILE_SIZE
offsetY.floatValue += size.height / 2F + TILE_SIZE offsetY += halvedY + TILE_SIZE
} }
} }
val tileOffsetX = (offsetX.floatValue / TILE_SIZE).toInt() val tileOffsetX = (offsetX / TILE_SIZE).toInt()
val tileOffsetY = (offsetY.floatValue / TILE_SIZE).toInt() val tileOffsetY = (offsetY / TILE_SIZE).toInt()
val strippedOffsetX = offsetX.floatValue % TILE_SIZE val centerTileX = (1 + (offsetX + halvedX) / TILE_SIZE).toDouble()
val strippedOffsetY = offsetY.floatValue % TILE_SIZE val centerTileY = (1 + (offsetY + halvedY) / TILE_SIZE).toDouble()
val strippedOffsetX = offsetX % TILE_SIZE
val strippedOffsetY = offsetY % TILE_SIZE
val offset = Offset(strippedOffsetX, strippedOffsetY) val offset = Offset(strippedOffsetX, strippedOffsetY)
@ -81,6 +105,12 @@ fun MapCanvas(
val tiles = tileContainer.getTiles(tileOffsetX, tileOffsetY, tileOffsetX + gridWidth + 2, tileOffsetY + gridHeight + 2, level) val tiles = tileContainer.getTiles(tileOffsetX, tileOffsetY, tileOffsetX + gridWidth + 2, tileOffsetY + gridHeight + 2, level)
val crossRadius = 24F
val additionalSize = if (debug) 96F else 0F
val latLonSize = Size(216F, 96F + additionalSize)
val latLonOffset = Offset(16F, 16F)
for (cellX in 0 .. gridWidth + 2) { for (cellX in 0 .. gridWidth + 2) {
val tileX = tileOffsetX + cellX val tileX = tileOffsetX + cellX
val localOffsetX = TILE_SIZE * (cellX - 1) val localOffsetX = TILE_SIZE * (cellX - 1)
@ -88,20 +118,23 @@ fun MapCanvas(
val tileY = tileOffsetY + cellY val tileY = tileOffsetY + cellY
val localOffsetY = TILE_SIZE * (cellY - 1) val localOffsetY = TILE_SIZE * (cellY - 1)
val bitmap = tiles.find { it.x == tileX && it.y == tileY && it.level == level }?.toBitmap() val tile = tiles.find { it.x == tileX && it.y == tileY && it.level == level }!!
val bitmap = tile.toBitmap()
val totalOffset = Offset(localOffsetX, localOffsetY) - offset
bitmap?.let { bitmap?.let {
val imageBitmap = bitmap.asImageBitmap() val imageBitmap = bitmap.asImageBitmap()
drawImage( drawImage(
image = imageBitmap, image = imageBitmap,
topLeft = Offset(localOffsetX, localOffsetY) - offset topLeft = totalOffset
) )
} }
/*
if (debug) {
drawRect( drawRect(
color = gridColor, color = gridColor,
size = Size(TILE_SIZE, TILE_SIZE), size = Size(TILE_SIZE, TILE_SIZE),
topLeft = Offset(localOffsetX, localOffsetY) - offset, topLeft = totalOffset,
style = Stroke(width = 4F) style = Stroke(width = 4F)
) )
@ -110,14 +143,53 @@ fun MapCanvas(
text = buildAnnotatedString { text = buildAnnotatedString {
withStyle(ParagraphStyle(textAlign = TextAlign.Center)) { withStyle(ParagraphStyle(textAlign = TextAlign.Center)) {
withStyle(SpanStyle(color = gridColor)) { withStyle(SpanStyle(color = gridColor)) {
append("x:%d, y:%d".format(tileX, tileY)) val mercX = tile.mercateX()
val mercY = tile.mercateY()
append("%.6f, %.6f,\n%d, %d".format(mercX, mercY, tileX, tileY))
} }
} }
}, },
topLeft = Offset(localOffsetX, localOffsetY) - offset, topLeft = totalOffset,
size = Size(TILE_SIZE, TILE_SIZE) size = Size(TILE_SIZE, TILE_SIZE)
) )
*/ }
val path = Path()
path.moveTo(halvedX - crossRadius, halvedY)
path.lineTo(halvedX + crossRadius, halvedY)
path.moveTo(halvedX, halvedY - crossRadius)
path.lineTo(halvedX, halvedY + crossRadius)
path.close()
drawPath(
path,
Color.White,
style = Stroke(width = 6F)
)
drawRect(
color = backColor,
size = latLonSize,
topLeft = latLonOffset
)
drawText(
textMeasurer = textMeasurer,
text = buildAnnotatedString {
withStyle(ParagraphStyle(textAlign = TextAlign.Center)) {
withStyle(SpanStyle(color = gridColor)) {
val lon = SphereMercator.mercateX(centerTileX, level)
val lat = SphereMercator.mercateY(centerTileY, level)
append("%.6f\n%.6f".format(lon, lat))
if (debug) {
append("\n%.0f\n%.0f".format(offsetX, offsetY))
}
}
}
},
size = latLonSize,
topLeft = latLonOffset
)
} }
} }
} }

View file

@ -0,0 +1,58 @@
package com.mirenkov.ktheightmap
import java.lang.Math.toRadians
import java.lang.Math.toDegrees
import kotlin.math.atan
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.tan
import kotlin.math.PI
class SphereMercator {
companion object {
const val RADIUS = 6378137
const val METERS_PER_PIXEL = 156543.0339
fun x2lon(x: Double): Double {
return toDegrees(x / RADIUS)
}
fun y2lat(y: Double): Double {
return toDegrees(atan(exp(y / RADIUS / 2)) * 2 - PI / 2)
}
fun lon2x(longitude: Double): Double {
return toRadians(longitude) * RADIUS
}
fun lat2y(latitude: Double): Double {
return ln(tan(PI / 4 + toRadians(latitude) / 2)) * RADIUS
}
fun scaledMetersPerPixel(level: Int): Double {
return METERS_PER_PIXEL / ( 1 shl (level - 1) )
}
fun sx2lon(x: Double, level: Int): Double {
return x2lon(x * scaledMetersPerPixel(level))
}
fun sy2lat(y: Double, level: Int): Double {
return y2lat( y * scaledMetersPerPixel(level))
}
fun lon2sx(longitude: Double, level: Int) : Double {
return lon2x(longitude) / scaledMetersPerPixel(level)
}
fun lat2sy(latitude: Double, level: Int): Double {
return lat2y(latitude) / scaledMetersPerPixel(level)
}
fun mercateX(x: Double, level: Int): Double = sx2lon((x * TILE_SIZE), level) - 180.0
fun mercateY(y: Double, level: Int): Double = -sy2lat((y - ((1 shl (level - 1)).toDouble() / 2.0)) * TILE_SIZE, level - 1)
fun mercateLon(longitude: Double, level: Int, offset: Double): Double = lon2sx(longitude + 180.0, level) + offset
}
}

View file

@ -46,4 +46,8 @@ class Tile {
bitmap = BitmapFactory.decodeByteArray(ba, 0, ba.size) bitmap = BitmapFactory.decodeByteArray(ba, 0, ba.size)
return bitmap return bitmap
} }
fun mercateX(): Double = SphereMercator.mercateX(x.toDouble(), level)
fun mercateY(): Double = SphereMercator.mercateY(y.toDouble(), level)
} }

View file

@ -12,14 +12,12 @@ class TileRepository(private val tileDao: TileDao) {
fun pushTile(tile: Tile) { fun pushTile(tile: Tile) {
coroutineScope.launch(Dispatchers.IO) { tileDao.pushTile(tile) } coroutineScope.launch(Dispatchers.IO) { tileDao.pushTile(tile) }
} }
fun getTile(x: Int, y: Int, level: Int): Tile? { fun getTile(x: Int, y: Int, level: Int): Tile? {
val tileFuture = coroutineScope.future(Dispatchers.IO) { val tileFuture = coroutineScope.future(Dispatchers.IO) {
tileDao.getTile(x, y, level) tileDao.getTile(x, y, level)
} }
return tileFuture.join() return tileFuture.join()
} }
fun clearTiles() { fun clearTiles() {
coroutineScope.launch(Dispatchers.IO) { tileDao.clearTiles() } coroutineScope.launch(Dispatchers.IO) { tileDao.clearTiles() }
} }

View file

@ -1,12 +1,27 @@
package com.mirenkov.ktheightmap package com.mirenkov.ktheightmap
import android.app.Application import android.app.Application
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
class TileViewModel(application: Application): ViewModel() { class TileViewModel(application: Application): ViewModel() {
val repository: TileRepository val repository: TileRepository
var debug = mutableStateOf(false)
var logRequested = mutableStateOf(false)
var latitudeText = mutableStateOf(TextFieldValue(""))
var longitudeText = mutableStateOf(TextFieldValue(""))
val mapOffsetX = mutableFloatStateOf(-646.65625F)
val mapOffsetY = mutableFloatStateOf(-1157.2814F)
val scale = mutableFloatStateOf(1F)
var halvedOffsetX: Float? = null
var halvedOffsetY: Float? = null
init { init {
val tileDb = TileDB.getInstance(application) val tileDb = TileDB.getInstance(application)
val tileDao = tileDb.tileDao() val tileDao = tileDb.tileDao()