The map mode in the Find tab: an OSM-based map with marker clustering, tag filtering, location permission handling, camera state persistence, and KML export. The screen composable and its ViewModel are documented together.
Introduction
LocationMapScreen renders an offline-capable OpenStreetMap using MapCompose's MapUI with an OsmTileProvider. Nodes with GEO_COORDS properties are plotted as clustered markers. The screen supports: tag-based filtering via a bottom sheet, a "locate me" FAB with runtime permission requests, camera state save/restore via AppPreferences, and KML export via a share intent.
MapViewModel loads location nodes from RoamRepository.getNodesWithLocation(), extracts unique tags for filtering, manages selected node state for the detail card, and coordinates KML export through KmlExportService.
Design Decisions
**MapCompose over Google Maps SDK
MapCompose (ovh.plrapps) is a pure-Compose map library that renders map tiles from a custom tile provider. This was chosen over Google Maps SDK because: (1) it avoids the Play Services dependency, (2) it uses the same OSM tile source as the Emacs-based Arcology web viewer, maintaining visual consistency, and (3) it supports custom marker composables and clustering natively in Compose, avoiding the view interop layer that Google Maps Compose wrapper requires.
**Marker clustering over individual pins
Nodes are rendered using RenderingStrategy.Clustering with a ClusterMarker composable that shows a count badge. This avoids visual overload when zoomed out — a dense city with 50+ location nodes would be unreadable with individual pins. The clusterer automatically groups nearby markers and expands them on zoom in. At zoom level ~16+ (street level), individual markers replace clusters.
**Camera state persistence
The map saves its camera position (latitude, longitude, scale) to AppPreferences on dispose and restores it on next load. This means the user's view position persists across tab switches and app restarts. The auto-fit logic (center on all markers) only runs on first load when no saved camera state exists, preventing the "fighting the auto-fit" problem.
**Separate file system for KML export
KML export writes to the Android cache directory (context.cacheDir) and shares via FileProvider. The export is a one-shot operation — the temp file is not persisted beyond the share intent's lifecycle. This avoids cluttering the user's org-roam directory with generated files.
LocationNode
A data class pairing an OrgNode with its parsed GeoCoordinate and tags.
/**
* Represents a node with its geographic location.
*/
data class LocationNode(
val node: OrgNode,
val coordinate: GeoCoordinate,
val tags: List<String> = emptyList()
)MapViewModel
MapViewModel manages five state flows: locationNodes, isLoading, selectedNode, filterTags, and availableTags. It loads all geo-located nodes at init and on refresh(), extracts unique tags for the filter UI, and provides filtered-node access via getFilteredNodes().
Camera state methods (getMapCameraState, saveMapCameraState) delegate to AppPreferences for persistence. The exportToKml() method uses KmlExportService to serialize filtered nodes to KML and returns a share Intent.
package computer.whatthefuck.arcology.app.viewmodel
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import computer.whatthefuck.arcology.app.data.AppPreferences
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.GeoCoordinate
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.export.KmlExportService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
/**
* Represents a node with its geographic location.
*/
data class LocationNode(
val node: OrgNode,
val coordinate: GeoCoordinate,
val tags: List<String> = emptyList()
)
/**
* ViewModel for the location map screen.
*/
class MapViewModel(
private val repository: RoamRepository,
private val kmlExportService: KmlExportService,
private val appPreferences: AppPreferences
) : ViewModel() {
private val _locationNodes = MutableStateFlow<List<LocationNode>>(emptyList())
val locationNodes: StateFlow<List<LocationNode>> = _locationNodes.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _selectedNode = MutableStateFlow<LocationNode?>(null)
val selectedNode: StateFlow<LocationNode?> = _selectedNode.asStateFlow()
private val _filterTags = MutableStateFlow<Set<String>>(emptySet())
val filterTags: StateFlow<Set<String>> = _filterTags.asStateFlow()
private val _availableTags = MutableStateFlow<List<String>>(emptyList())
val availableTags: StateFlow<List<String>> = _availableTags.asStateFlow()
init {
loadLocations()
}
/**
* Refresh location data from the database.
*/
fun refresh() {
loadLocations()
}
/**
* Get saved map camera state.
*/
fun getMapCameraState(): Triple<Double, Double, Float>? {
return appPreferences.getMapCameraState()
}
/**
* Save map camera state for restoration.
*/
fun saveMapCameraState(latitude: Double, longitude: Double, scale: Float) {
appPreferences.setMapCameraState(latitude, longitude, scale)
}
/**
* Load all nodes that have location data.
*/
fun loadLocations() {
viewModelScope.launch {
_isLoading.value = true
try {
val nodesWithLocation = repository.getNodesWithLocation()
val locationNodes = nodesWithLocation.map { (node, coord) ->
val tags = repository.getTagsByNode(node.id)
LocationNode(node, coord, tags)
}
_locationNodes.value = locationNodes
// Extract all unique tags for filtering
val allTags = locationNodes.flatMap { it.tags }.distinct().sorted()
_availableTags.value = allTags
} finally {
_isLoading.value = false
}
}
}
/**
* Filter nodes by tags.
*/
fun setTagFilter(tags: Set<String>) {
_filterTags.value = tags
}
/**
* Toggle a tag in the filter.
*/
fun toggleTagFilter(tag: String) {
_filterTags.value = if (tag in _filterTags.value) {
_filterTags.value - tag
} else {
_filterTags.value + tag
}
}
/**
* Clear all tag filters.
*/
fun clearTagFilter() {
_filterTags.value = emptySet()
}
/**
* Get the currently filtered nodes.
*/
fun getFilteredNodes(): List<LocationNode> {
val tags = _filterTags.value
return if (tags.isEmpty()) {
_locationNodes.value
} else {
_locationNodes.value.filter { node ->
node.tags.any { it in tags }
}
}
}
/**
* Select a node (for showing details popup).
*/
fun selectNode(node: LocationNode?) {
_selectedNode.value = node
}
/**
* Export filtered locations to KML.
*
* @param context Android context for file operations
* @param documentName Name for the KML document
* @return Intent for sharing the KML file, or null on failure
*/
fun exportToKml(context: Context, documentName: String = "Arcology Locations"): Intent? {
val nodes = getFilteredNodes()
if (nodes.isEmpty()) return null
val kmlContent = kmlExportService.exportToKml(
nodes = nodes.map { it.node to it.coordinate },
documentName = documentName,
documentDescription = "Exported from Arcology"
)
return try {
// Create temp file
val file = File(context.cacheDir, "export.kml")
file.writeText(kmlContent)
// Create share intent
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
Intent(Intent.ACTION_SEND).apply {
type = KmlExportService.KML_MIME_TYPE
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
} catch (e: Exception) {
null
}
}
}LocationMapScreen Composable
LocationMapScreen manages: MapCompose state (MapState with tile layer and clusterer), runtime location permission flow, camera save/restore via DisposableEffect, marker rendering from filtered nodes, and overlay UI (filter/sort buttons, tag filter chips, locate-me FAB, node detail card).
The screen uses MapUtils from the app's map module for geo/normalized coordinate conversion and bounding box calculations.
LocationMapScreen Source Code
The complete Kotlin source file containing LocationMapScreen, MarkerPin, NodeDetailsCard, and ClusterMarker composables.
mapscreen-preamble
package computer.whatthefuck.arcology.app.ui.screens
import android.Manifest
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import computer.whatthefuck.arcology.app.data.LocationService
import computer.whatthefuck.arcology.app.map.MapUtils
import computer.whatthefuck.arcology.app.map.OsmTileProvider
import computer.whatthefuck.arcology.domain.GeoCoordinate
import computer.whatthefuck.arcology.app.viewmodel.LocationNode
import computer.whatthefuck.arcology.app.viewmodel.MapViewModel
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import ovh.plrapps.mapcompose.api.*
import ovh.plrapps.mapcompose.ui.MapUI
import ovh.plrapps.mapcompose.ui.state.MapState
import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy
import kotlin.math.powmapscreen-composable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun LocationMapScreen(
viewModel: MapViewModel = koinViewModel(),
onNodeSelected: (nodeId: String) -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val locationService: LocationService = koinInject()
val locationNodes by viewModel.locationNodes.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val selectedNode by viewModel.selectedNode.collectAsState()
val filterTags by viewModel.filterTags.collectAsState()
val availableTags by viewModel.availableTags.collectAsState()
var showFilterSheet by remember { mutableStateOf(false) }
var isLocating by remember { mutableStateOf(false) }
var hasLocationPermission by remember { mutableStateOf(locationService.hasLocationPermission()) }
// Track whether we have a saved camera state to skip auto-fit
val savedCameraState = remember { viewModel.getMapCameraState() }
// Create tile provider
val tileProvider = remember { OsmTileProvider(context) }
// Create map state with clustering
val mapState = remember {
MapState(
levelCount = OsmTileProvider.MAX_ZOOM + 1,
fullWidth = OsmTileProvider.TILE_SIZE * 2.0.pow(OsmTileProvider.MAX_ZOOM.toDouble()).toInt(),
fullHeight = OsmTileProvider.TILE_SIZE * 2.0.pow(OsmTileProvider.MAX_ZOOM.toDouble()).toInt(),
workerCount = 4
).apply {
addLayer(tileProvider)
// Add clusterer for grouping nearby markers
addClusterer("locations") { ids ->
{ ClusterMarker(count = ids.count()) }
}
// Start with saved scale or default world view
scale = savedCameraState?.third?.toDouble() ?: 0.0001
}
}
// Permission launcher - must be after mapState definition
val locationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
hasLocationPermission = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
if (hasLocationPermission) {
// Fetch location after permission granted
scope.launch {
isLocating = true
val location = locationService.getCurrentLocation()
if (location != null) {
val (x, y) = MapUtils.geoToNormalized(location)
// Set scale first, then scroll
mapState.scale = 0.01 // City level zoom
mapState.scrollTo(x, y)
}
isLocating = false
}
}
}
// Restore camera position on load
LaunchedEffect(Unit) {
savedCameraState?.let { (lat, lon, _) ->
val (x, y) = MapUtils.geoToNormalized(GeoCoordinate(lat, lon))
mapState.scrollTo(x, y)
}
}
// Save camera position on exit
DisposableEffect(Unit) {
onDispose {
val coord = MapUtils.normalizedToGeo(mapState.centroidX, mapState.centroidY)
viewModel.saveMapCameraState(coord.latitude, coord.longitude, mapState.scale.toFloat())
}
}
// Get filtered nodes
val filteredNodes = remember(locationNodes, filterTags) {
viewModel.getFilteredNodes()
}
// Track if this is the first load (for auto-fit logic)
var isFirstLoad by remember { mutableStateOf(true) }
// Add markers for nodes
LaunchedEffect(filteredNodes) {
// Clear existing markers
mapState.removeAllMarkers()
// Add markers for each node with clustering
filteredNodes.forEach { locationNode ->
val (x, y) = MapUtils.geoToNormalized(locationNode.coordinate)
mapState.addMarker(
id = locationNode.node.id,
x = x,
y = y,
renderingStrategy = RenderingStrategy.Clustering("locations")
) {
MarkerPin(
title = locationNode.node.title ?: "Location",
isSelected = selectedNode?.node?.id == locationNode.node.id,
onClick = { viewModel.selectNode(locationNode) }
)
}
}
// If we have nodes and no saved camera state, fit the map to show them all
// Only do this on first load to avoid jumping when filter changes
if (filteredNodes.isNotEmpty() && isFirstLoad && savedCameraState == null) {
val coords = filteredNodes.map { it.coordinate }
MapUtils.boundingBox(coords)?.let { (sw, ne) ->
val center = MapUtils.center(sw, ne)
val (cx, cy) = MapUtils.geoToNormalized(center)
mapState.scrollTo(cx, cy)
}
}
isFirstLoad = false
}
Box(modifier = Modifier.fillMaxSize()) {
// Map
MapUI(
modifier = Modifier.fillMaxSize(),
state = mapState
)
// Loading indicator
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.Center)
.size(48.dp)
)
}
// Top bar with filter and export buttons
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.align(Alignment.TopCenter),
horizontalArrangement = Arrangement.SpaceBetween
) {
// Location count
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)
) {
Text(
text = "${filteredNodes.size} locations",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Filter button
if (availableTags.isNotEmpty()) {
FloatingActionButton(
onClick = { showFilterSheet = true },
containerColor = if (filterTags.isNotEmpty())
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surface
) {
Icon(Icons.Default.FilterList, contentDescription = "Filter")
}
}
// Export button
FloatingActionButton(
onClick = {
val intent = viewModel.exportToKml(context)
if (intent != null) {
context.startActivity(Intent.createChooser(intent, "Share KML"))
}
},
containerColor = MaterialTheme.colorScheme.surface
) {
Icon(Icons.Default.Share, contentDescription = "Export KML")
}
}
}
// Tag filter chips (if any tags are selected)
if (filterTags.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 80.dp)
.align(Alignment.TopStart)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
filterTags.forEach { tag ->
FilterChip(
selected = true,
onClick = { viewModel.toggleTagFilter(tag) },
label = { Text(tag) },
trailingIcon = {
Icon(
Icons.Default.Close,
contentDescription = "Remove filter",
modifier = Modifier.size(16.dp)
)
}
)
}
TextButton(onClick = { viewModel.clearTagFilter() }) {
Text("Clear all")
}
}
}
// Locate me FAB
FloatingActionButton(
onClick = {
if (!hasLocationPermission) {
locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
} else {
scope.launch {
isLocating = true
val location = locationService.getCurrentLocation()
if (location != null) {
// Center map on current location with city-level zoom
val (x, y) = MapUtils.geoToNormalized(location)
// Set scale first, then scroll
mapState.scale = 0.01 // City level zoom
mapState.scrollTo(x, y)
}
isLocating = false
}
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
.padding(bottom = if (selectedNode != null) 200.dp else 0.dp),
containerColor = MaterialTheme.colorScheme.primaryContainer
) {
if (isLocating) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Icon(
Icons.Default.MyLocation,
contentDescription = "Locate me"
)
}
}
// Selected node details
selectedNode?.let { node ->
NodeDetailsCard(
locationNode = node,
onClose = { viewModel.selectNode(null) },
onOpen = { onNodeSelected(node.node.id) },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
)
}
}
// Filter bottom sheet
if (showFilterSheet) {
ModalBottomSheet(
onDismissRequest = { showFilterSheet = false }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
"Filter by tags",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
availableTags.forEach { tag ->
FilterChip(
selected = tag in filterTags,
onClick = { viewModel.toggleTagFilter(tag) },
label = { Text(tag) }
)
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}mapscreen-markerpin
@Composable
private fun MarkerPin(
title: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable(onClick = onClick)
) {
Icon(
Icons.Default.Place,
contentDescription = title,
tint = if (isSelected) MaterialTheme.colorScheme.primary else Color.Red,
modifier = Modifier.size(if (isSelected) 40.dp else 32.dp)
)
if (isSelected) {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)
) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(4.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}mapscreen-nodedetails
@Composable
private fun NodeDetailsCard(
locationNode: LocationNode,
onClose: () -> Unit,
onOpen: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = locationNode.node.title ?: "Untitled",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
if (locationNode.node.outlinePath.isNotEmpty()) {
Text(
text = locationNode.node.outlinePath.joinToString(" > "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = "%.4f, %.4f".format(
locationNode.coordinate.latitude,
locationNode.coordinate.longitude
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (locationNode.tags.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
locationNode.tags.forEach { tag ->
SuggestionChip(
onClick = { },
label = { Text(tag, style = MaterialTheme.typography.labelSmall) }
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onOpen,
modifier = Modifier.fillMaxWidth()
) {
Text("Open")
}
}
}
}mapscreen-clustermarker
@Composable
private fun ClusterMarker(count: Int) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.border(2.dp, MaterialTheme.colorScheme.onPrimary, CircleShape),
contentAlignment = Alignment.Center
) {
Text(
text = if (count > 99) "99+" else count.toString(),
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.labelMedium
)
}
}MapViewModel Assembly
<<map-viewmodel>>LocationMapScreen Assembly
<<mapscreen-preamble>>
<<mapscreen-composable>>
<<mapscreen-markerpin>>
<<mapscreen-nodedetails>>
<<mapscreen-clustermarker>>Map Platform Components
Two utility classes in app.map that provide the map infrastructure:
MapUtils— a utility object for Web Mercator (EPSG:3857) coordinate conversions. Converts between geographic coordinates (latitude/longitude) and normalized map coordinates (0.0-1.0 range used by MapCompose). Provides bounding box calculation and center point computation for auto-fitting the map view to a set of location markers.OsmTileProvider— an OpenStreetMap tile fetcher implementing MapCompose'sTileStreamProviderinterface. Downloads tiles from tile.openstreetmap.org and caches them tocacheDir/osm_tiles/{z}/{x}/{y}.png. Includes cache clearing and cache size query utilities.
Design decision: simple file-system cache over SQLite blob cache. Tile caching uses the filesystem directly rather than a database table. This is the standard OSM tile cache pattern (the filesystem IS the cache database for raster tiles). The cache dir structure mirrors the OSM tile URL pattern for easy debugging and manual inspection.
arcology.app.map.MapUtils
import computer.whatthefuck.arcology.domain.GeoCoordinate
import kotlin.math.*
object MapUtils {
const val MAX_LATITUDE = 85.0511287798
fun geoToNormalized(coord: GeoCoordinate): Pair<Double, Double> {
return geoToNormalized(coord.latitude, coord.longitude)
}
fun geoToNormalized(latitude: Double, longitude: Double): Pair<Double, Double> {
val lat = latitude.coerceIn(-MAX_LATITUDE, MAX_LATITUDE)
val x = (longitude + 180.0) / 360.0
val latRad = Math.toRadians(lat)
val mercatorY = ln(tan(PI / 4 + latRad / 2))
val y = (1.0 - mercatorY / PI) / 2.0
return x to y
}
fun normalizedToGeo(x: Double, y: Double): GeoCoordinate {
val longitude = x * 360.0 - 180.0
val mercatorY = (1.0 - 2.0 * y) * PI
val latitude = Math.toDegrees(2 * atan(exp(mercatorY)) - PI / 2)
return GeoCoordinate(latitude, longitude)
}
fun boundingBox(coords: List<GeoCoordinate>): Pair<GeoCoordinate, GeoCoordinate>? {
if (coords.isEmpty()) return null
var minLat = Double.MAX_VALUE
var maxLat = Double.MIN_VALUE
var minLon = Double.MAX_VALUE
var maxLon = Double.MIN_VALUE
for (coord in coords) {
minLat = minOf(minLat, coord.latitude)
maxLat = maxOf(maxLat, coord.latitude)
minLon = minOf(minLon, coord.longitude)
maxLon = maxOf(maxLon, coord.longitude)
}
return GeoCoordinate(minLat, minLon) to GeoCoordinate(maxLat, maxLon)
}
fun center(sw: GeoCoordinate, ne: GeoCoordinate): GeoCoordinate {
return GeoCoordinate(
latitude = (sw.latitude + ne.latitude) / 2,
longitude = (sw.longitude + ne.longitude) / 2
)
}
}package computer.whatthefuck.arcology.app.map
<<map-utils>>arcology.app.map.OsmTileProvider
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ovh.plrapps.mapcompose.core.TileStreamProvider
import java.io.File
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
class OsmTileProvider(
private val context: Context,
private val userAgent: String = "Arcology/1.0 (Android)",
private val tileServerUrl: String = "https://tile.openstreetmap.org"
) : TileStreamProvider {
private val cacheDir: File by lazy {
File(context.cacheDir, "osm_tiles").also { it.mkdirs() }
}
override suspend fun getTileStream(row: Int, col: Int, zoomLvl: Int): InputStream? = withContext(Dispatchers.IO) {
val x = col
val y = row
val z = zoomLvl
val cacheFile = getCacheFile(z, x, y)
if (cacheFile.exists() && cacheFile.length() > 0) {
return@withContext try {
cacheFile.inputStream()
} catch (e: Exception) {
null
}
}
return@withContext try {
fetchAndCacheTile(z, x, y, cacheFile)
} catch (e: Exception) {
null
}
}
private fun getCacheFile(z: Int, x: Int, y: Int): File {
val dir = File(cacheDir, "$z/$x")
dir.mkdirs()
return File(dir, "$y.png")
}
private fun fetchAndCacheTile(z: Int, x: Int, y: Int, cacheFile: File): InputStream? {
val url = URL("$tileServerUrl/$z/$x/$y.png")
val connection = url.openConnection() as HttpURLConnection
return try {
connection.apply {
requestMethod = "GET"
setRequestProperty("User-Agent", userAgent)
connectTimeout = 10000
readTimeout = 10000
}
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
val bytes = connection.inputStream.readBytes()
try {
cacheFile.parentFile?.mkdirs()
cacheFile.writeBytes(bytes)
} catch (e: Exception) {
}
bytes.inputStream()
} else {
null
}
} finally {
connection.disconnect()
}
}
suspend fun clearCache() = withContext(Dispatchers.IO) {
cacheDir.deleteRecursively()
cacheDir.mkdirs()
}
suspend fun getCacheSize(): Long = withContext(Dispatchers.IO) {
cacheDir.walkTopDown()
.filter { it.isFile }
.sumOf { it.length() }
}
companion object {
const val MIN_ZOOM = 0
const val MAX_ZOOM = 19
const val TILE_SIZE = 256
}
}package computer.whatthefuck.arcology.app.map
<<map-osmtileprovider>>KML Data Models
The KML serialization model uses `@Serializable` data classes with `kotlinx.serialization.xmlutil`. Each KML element maps to a typed data class — `Kml` → `KmlDocument` → `KmlPlacemark` → `KmlPoint` — with the KML 2.2 namespace annotation on each element. This avoids string-building XML (the 90s are over), while keeping the model readable and testable.
The models live in `src/commonMain` because they're pure data — no Android or JVM dependencies — and could theoretically be reused by a future JVM publishing binary.
package computer.whatthefuck.arcology.export
import kotlinx.serialization.Serializable
import nl.adaptivity.xmlutil.serialization.XmlElement
import nl.adaptivity.xmlutil.serialization.XmlSerialName
/**
* KML (Keyhole Markup Language) data model for export.
* See: https://developers.google.com/kml/documentation/kmlreference
*/
private const val KML_NAMESPACE = "http://www.opengis.net/kml/2.2"
@Serializable
@XmlSerialName("kml", KML_NAMESPACE, "")
data class Kml(
@XmlElement(true)
val document: KmlDocument
)
@Serializable
@XmlSerialName("Document", KML_NAMESPACE, "")
data class KmlDocument(
@XmlElement(true)
val name: String,
@XmlElement(true)
val description: String? = null,
@XmlSerialName("Placemark", KML_NAMESPACE, "")
val placemarks: List<KmlPlacemark> = emptyList()
)
@Serializable
@XmlSerialName("Placemark", KML_NAMESPACE, "")
data class KmlPlacemark(
@XmlElement(true)
val name: String,
@XmlElement(true)
val description: String? = null,
@XmlElement(true)
val point: KmlPoint? = null
)
@Serializable
@XmlSerialName("Point", KML_NAMESPACE, "")
data class KmlPoint(
/**
* KML coordinates format: longitude,latitude,altitude
* Note: KML uses longitude first (opposite of GPS convention).
*/
@XmlElement(true)
val coordinates: String
)KML Export Service
`KmlExportService` wraps the serialization models into a one-shot export function: take a list of `OrgNode` × `GeoCoordinate` pairs, a document name, and an optional description, and produce a KML XML string.
The service is instantiated as a singleton by Koin (see App Bootstrap) and passed to `MapViewModel`. It's only used to export the currently visible/filtered set of map markers — there's no batch export of every node with coordinates, no scheduled export, and no KML import path.
Each placemark's description is built from node metadata: outline path, file reference, TODO state, priority, and scheduled/deadline dates. The KML coordinates come from `GeoCoordinate.toKmlCoordinates()` (defined in roam/models.org), which produces the KML-standard `longitude,latitude,altitude` ordering. The MIME type and file extension constants are exposed as `companion object` members for use by `MapViewModel.exportToKml()`.
package computer.whatthefuck.arcology.export
import computer.whatthefuck.arcology.domain.GeoCoordinate
import computer.whatthefuck.arcology.domain.OrgNode
import nl.adaptivity.xmlutil.serialization.XML
/**
* Service for exporting nodes with location data to KML format.
* KML files can be opened in Google Earth, Google Maps, and other mapping applications.
*/
class KmlExportService {
private val xml = XML {
indent = 2
xmlDeclMode = nl.adaptivity.xmlutil.XmlDeclMode.Charset
}
/**
* Export a list of nodes with coordinates to KML format.
*
* @param nodes List of node/coordinate pairs to export
* @param documentName Name for the KML document (shown in map applications)
* @param documentDescription Optional description for the document
* @return KML XML string
*/
fun exportToKml(
nodes: List<Pair<OrgNode, GeoCoordinate>>,
documentName: String,
documentDescription: String? = null
): String {
val placemarks = nodes.map { (node, coord) ->
KmlPlacemark(
name = node.title ?: "Untitled",
description = buildPlacemarkDescription(node),
point = KmlPoint(coordinates = coord.toKmlCoordinates())
)
}
val kml = Kml(
document = KmlDocument(
name = documentName,
description = documentDescription,
placemarks = placemarks
)
)
return xml.encodeToString(Kml.serializer(), kml)
}
/**
* Export a single node to KML format.
*/
fun exportSingleToKml(
node: OrgNode,
coordinate: GeoCoordinate,
documentName: String? = null
): String {
return exportToKml(
nodes = listOf(node to coordinate),
documentName = documentName ?: node.title ?: "Location"
)
}
/**
* Build a description for a placemark from node metadata.
*/
private fun buildPlacemarkDescription(node: OrgNode): String? {
val parts = mutableListOf<String>()
// Add outline path if available
if (node.outlinePath.isNotEmpty()) {
parts.add("Path: ${node.outlinePath.joinToString(" > ")}")
}
// Add file reference
parts.add("File: ${node.file}")
// Add TODO state if present
node.todo?.let { parts.add("Status: $it") }
// Add priority if present
node.priority?.let { parts.add("Priority: $it") }
// Add scheduled/deadline if present
node.scheduled?.let { parts.add("Scheduled: $it") }
node.deadline?.let { parts.add("Deadline: $it") }
return if (parts.isNotEmpty()) parts.joinToString("\n") else null
}
companion object {
/**
* MIME type for KML files.
*/
const val KML_MIME_TYPE = "application/vnd.google-earth.kml+xml"
/**
* File extension for KML files.
*/
const val KML_EXTENSION = ".kml"
}
}Related Modules
App Bootstrap — Koin DI provides
MapViewModelwithRoamRepository,KmlExportService,AppPreferencesApp Data Layer —
AppPreferencesfor camera state persistence,LocationServicefor GPSSearch Screen — cross-mode wiring via
FindScreencontainerModels & Repository —
RoamRepository.getNodesWithLocation()for geo queries