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>
<SelectionState runConfigName="app">
<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">
<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>
</Target>
</DropdownSelection>

View file

@ -3,33 +3,47 @@ package com.mirenkov.ktheightmap
import android.app.Application
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
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.Icon
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
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.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@ -72,7 +86,7 @@ class MainActivity : ComponentActivity() {
@Composable
fun Main(vm: TileViewModel = viewModel()) {
val sliderValue = rememberSaveable { mutableFloatStateOf(1F) }
var scale by rememberSaveable { vm.scale }
val coroutineScope = rememberCoroutineScope()
val tileContainer = TileContainer(vm, coroutineScope)
KtHeightMapTheme {
@ -81,14 +95,14 @@ fun Main(vm: TileViewModel = viewModel()) {
modifier = Modifier.fillMaxSize()
.safeDrawingPadding()
.align(Alignment.Center),
floatingActionButton = { ToolButton() }
floatingActionButton = { ToolButton(vm) }
) { innerPadding ->
MapCanvas(
viewModel = vm,
gridColor = colorScheme.primary,
backColor = colorScheme.background,
scale = sliderValue,
tileContainer = tileContainer,
modifier = Modifier.padding(innerPadding),
modifier = Modifier.padding(innerPadding)
)
}
Box(modifier = Modifier.safeDrawingPadding()
@ -98,8 +112,8 @@ fun Main(vm: TileViewModel = viewModel()) {
.offset(0.dp, 60.dp)
) {
Slider(
value = sliderValue.floatValue,
onValueChange = { sliderValue.floatValue = it },
value = scale,
onValueChange = { scale = it },
valueRange = 1F..14F,
modifier = Modifier.align(Alignment.CenterStart)
)
@ -109,33 +123,97 @@ fun Main(vm: TileViewModel = viewModel()) {
}
@Composable
fun ToolButton() {
fun ToolButton(viewModel: TileViewModel) {
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(
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)
context.startActivity(intent)
}
) { Icon(Icons.Filled.Build, contentDescription = "Tools") }
)
SetLocationDialog(viewModel, dialogShown)
}
}
}
@Preview(showBackground = true)
@Composable
fun MainPreview() {
KtHeightMapTheme {
Scaffold(
modifier = Modifier.safeDrawingPadding(),
floatingActionButton = { ToolButton() }
) { innerPadding ->
Box(
modifier = Modifier.padding(innerPadding)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Gryadki"
fun SetLocationDialog(vm: TileViewModel, dialogShown: MutableState<Boolean>) {
var showLocationDialog by dialogShown
if (showLocationDialog) {
var latitudeText by remember { vm.latitudeText }
var longitudeText by remember { vm.longitudeText }
AlertDialog (
onDismissRequest = { showLocationDialog = false },
title = { Text("Input coordinates") },
text = {
Column {
TextField(
value = latitudeText,
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
import android.util.Log
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
@ -25,52 +30,71 @@ import kotlin.math.absoluteValue
@Composable
fun MapCanvas(
viewModel: TileViewModel,
backColor: Color,
gridColor: Color,
scale: MutableFloatState,
tileContainer: TileContainer,
modifier: Modifier = Modifier
) {
val offsetX = rememberSaveable { mutableFloatStateOf(-TILE_SIZE) }
val offsetY = rememberSaveable { mutableFloatStateOf(-TILE_SIZE) }
var offsetX by rememberSaveable { viewModel.mapOffsetX }
var offsetY by rememberSaveable { viewModel.mapOffsetY }
val scale by rememberSaveable { viewModel.scale }
val textMeasurer = rememberTextMeasurer()
val debug by rememberSaveable { viewModel.debug }
var logRequested by rememberSaveable { viewModel.logRequested }
Canvas(
modifier = modifier.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures { _, distance ->
offsetX.floatValue -= distance.x
offsetY.floatValue -= distance.y
offsetX -= distance.x
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(
color = backColor,
size = size
)
if (logRequested) {
Log.i(TAG, "Offset: %.6f, %.6f".format(offsetX, offsetY))
logRequested = false
}
val oldLevel = tileContainer.getLevel()
val level = scale.floatValue.toInt()
val level = scale.toInt()
val levelDiff = level - oldLevel
if (levelDiff < 0) {
repeat (levelDiff.absoluteValue) {
offsetX.floatValue -= size.width / 2F + TILE_SIZE
offsetY.floatValue -= size.height / 2F + TILE_SIZE
offsetX.floatValue /= 2F
offsetY.floatValue /= 2F
offsetX -= halvedX + TILE_SIZE
offsetY -= halvedY + TILE_SIZE
offsetX /= 2F
offsetY /= 2F
}
} else if (levelDiff > 0) {
repeat (levelDiff) {
offsetX.floatValue *= 2F
offsetY.floatValue *= 2F
offsetX.floatValue += size.width / 2F + TILE_SIZE
offsetY.floatValue += size.height / 2F + TILE_SIZE
offsetX *= 2F
offsetY *= 2F
offsetX += halvedX + TILE_SIZE
offsetY += halvedY + TILE_SIZE
}
}
val tileOffsetX = (offsetX.floatValue / TILE_SIZE).toInt()
val tileOffsetY = (offsetY.floatValue / TILE_SIZE).toInt()
val tileOffsetX = (offsetX / TILE_SIZE).toInt()
val tileOffsetY = (offsetY / TILE_SIZE).toInt()
val strippedOffsetX = offsetX.floatValue % TILE_SIZE
val strippedOffsetY = offsetY.floatValue % TILE_SIZE
val centerTileX = (1 + (offsetX + halvedX) / TILE_SIZE).toDouble()
val centerTileY = (1 + (offsetY + halvedY) / TILE_SIZE).toDouble()
val strippedOffsetX = offsetX % TILE_SIZE
val strippedOffsetY = offsetY % TILE_SIZE
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 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) {
val tileX = tileOffsetX + cellX
val localOffsetX = TILE_SIZE * (cellX - 1)
@ -88,20 +118,23 @@ fun MapCanvas(
val tileY = tileOffsetY + cellY
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 {
val imageBitmap = bitmap.asImageBitmap()
drawImage(
image = imageBitmap,
topLeft = Offset(localOffsetX, localOffsetY) - offset
topLeft = totalOffset
)
}
/*
if (debug) {
drawRect(
color = gridColor,
size = Size(TILE_SIZE, TILE_SIZE),
topLeft = Offset(localOffsetX, localOffsetY) - offset,
topLeft = totalOffset,
style = Stroke(width = 4F)
)
@ -110,14 +143,53 @@ fun MapCanvas(
text = buildAnnotatedString {
withStyle(ParagraphStyle(textAlign = TextAlign.Center)) {
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)
)
*/
}
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)
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) {
coroutineScope.launch(Dispatchers.IO) { tileDao.pushTile(tile) }
}
fun getTile(x: Int, y: Int, level: Int): Tile? {
val tileFuture = coroutineScope.future(Dispatchers.IO) {
tileDao.getTile(x, y, level)
}
return tileFuture.join()
}
fun clearTiles() {
coroutineScope.launch(Dispatchers.IO) { tileDao.clearTiles() }
}

View file

@ -1,12 +1,27 @@
package com.mirenkov.ktheightmap
import android.app.Application
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
class TileViewModel(application: Application): ViewModel() {
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 {
val tileDb = TileDB.getInstance(application)
val tileDao = tileDb.tileDao()