Arcology Engine

Map Screen — Location Map, Filtering & GPX Export

Contents

The map mode in the Find tab: an OSM-based map with marker clustering, tag filtering, location permission handling, camera state persistence, and GPX 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 GPX 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 GPX export through GpxExportService (maps/import-export.org).

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 GPX export

GPX 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. (KML export was removed 2026-10-10 when it was replaced by the GPX service — see maps/import-export.org.)

LocationNode

A data class pairing an OrgNode with its parsed GeoCoordinate and tags.

kotlin#+name: map-locationnode
/**
 * 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 exportToGpx() method builds a GpxExportService against the currently selected tree (via the DI factory), serializes the filtered nodes to GPX and returns a share Intent; it is suspend, launched from a rememberCoroutineScope in the screen.

kotlin#+name: map-viewmodel
package computer.whatthefuck.arcology.app.viewmodel

import android.content.Context
import android.content.Intent
import android.net.Uri
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.gis.GpxExportService
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 gpxExportServiceFactory: (Uri) -> GpxExportService,
    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 GPX (OsmAnd favorites format).
     *
     * Builds the service against the currently selected org tree (bodies are
     * read from the files). Suspend: the composable launches it from a
     * rememberCoroutineScope around the share FAB.
     *
     * @param context Android context for file operations
     * @param documentName Name for the GPX metadata
     * @return Intent for sharing the GPX file, or null on failure
     */
    suspend fun exportToGpx(context: Context, documentName: String = "Arcology Locations"): Intent? =
        // bodies read per file + 200 points of node building: off the UI
        // thread, or the share FAB freezes the map screen mid-export
        kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
            exportToGpxInternal(context, documentName)
        }

    private suspend fun exportToGpxInternal(
        context: Context,
        documentName: String
    ): Intent? {
        val nodes = getFilteredNodes()
        if (nodes.isEmpty()) return null
        val treeUri = appPreferences.getSelectedDirectoryUri() ?: return null
        val service = gpxExportServiceFactory(treeUri)

        val export = service.exportToGpx(
            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.gpx")
            file.writeText(export.content)

            // Create share intent
            val uri = FileProvider.getUriForFile(
                context,
                "${context.packageName}.fileprovider",
                file
            )

            Intent(Intent.ACTION_SEND).apply {
                type = GpxExportService.GPX_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

kotlin#+name: 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.pow

mapscreen-composable

kotlin#+name: mapscreen-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 = {
                        scope.launch {
                            val intent = viewModel.exportToGpx(context)
                            if (intent != null) {
                                context.startActivity(Intent.createChooser(intent, "Share GPX"))
                            }
                        }
                    },
                    containerColor = MaterialTheme.colorScheme.surface
                ) {
                    Icon(Icons.Default.Share, contentDescription = "Export GPX")
                }
            }
        }

        // 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

kotlin#+name: 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

kotlin#+name: 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

kotlin#+name: 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

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/viewmodel/MapViewModel.kt:noweb yes
<<map-viewmodel>>

LocationMapScreen Assembly

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/screens/LocationMapScreen.kt:noweb yes
<<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's TileStreamProvider interface. Downloads tiles from tile.openstreetmap.org and caches them to cacheDir/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

kotlin#+name: map-utils
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
        )
    }
}
kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/map/MapUtils.kt:noweb yes
package computer.whatthefuck.arcology.app.map

<<map-utils>>

arcology.app.map.OsmTileProvider

kotlin#+name: 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
    }
}
kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/map/OsmTileProvider.kt:noweb yes
package computer.whatthefuck.arcology.app.map

<<map-osmtileprovider>>

Related Modules

  • App Bootstrap — Koin DI provides MapViewModel with RoamRepository, KmlExportService, AppPreferences

  • App Data Layer — AppPreferences for camera state persistence, LocationService for GPS

  • Search Screen — cross-mode wiring via FindScreen container

  • Models & Repository — RoamRepository.getNodesWithLocation() for geo queries