Arcology Engine

Map Screen — Location Map, Filtering & GPX Export

Contents

The map mode in the Find tab: a MapTiler-hosted MapLibre GL map (the MapTiler SDK Kotlin, //docs.maptiler.com/mobile-sdk/android/) with GL-side 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 a vector GL map via the SDK's MTMapView composable + MTMapViewController (a WebView-bridged MapLibre GL JS under the hood). Nodes with GEO_COORDS properties are plotted as a clustered GeoJSON source (large contrast-stroked circles stepped by point_count, white count labels on clusters, blank labels on singles). Tapping a cluster zooms in so the blob splits into its member pins. The screen supports: tag-based filtering via a bottom sheet, a "locate me" FAB backed by the SDK's MTLocationManager, camera state save/restore via AppPreferences, GPX export via a share intent, and cluster interaction: big contrast-stroked blobs carry white count labels, tapping a splittable blob zooms in until it splits, and a co-located (same-coordinate) blob opens a member-list card since no zoom can split it. The API key and style are configured in SettingsScreen (=mapTilerApiKey=/=mapStyle= prefs).

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

**MapTiler SDK Kotlin over MapCompose (supersedes MapCompose over Google Maps)

The screen ran on ovh.plrapps:mapcompose (a raster-tile pan/zoom canvas with a hand-rolled OsmTileProvider fetching tile.openstreetmap.org PNGs and a MapUtils Web Mercator layer) until 2026-10-10, when the maps cluster went all-in on MapTiler (maps/web.org): the same Cloud account backs the published web pages and the phone screens, the custom designer style works identically on both, and satellite/3D styles are a settings field instead of a second tile provider. The SDK is a WebView-bridged MapLibre GL JS — heavier per-instance than MapCompose, but real vector tiles, GL clustering, and the full MapTiler style catalog. The =MapUtils=/=OsmTileProvider= pair was deleted with the swap; LocationService stays for the capture flow; the phone app now consumes the same MapTiler session-based quota the web map does.

**GL clustering over the Compose clusterer

MapCompose's RenderingStrategy.Clustering died with the swap; the SDK has no marker-level clustering, so points go into an MTGeoJSONSource with =isCluster=true= and two layers: a circle layer whose radius/color step on point_count (14/22/30px blue-orange-red ramp, 2.5px white stroke) plus a symbol layer whose text field is point_count_abbreviated (empty for unclustered singles, so no cluster-vs-point filters are needed) with white 13sp count labels that always draw. All styling rides the layer objects inline — see the inline-layer-styling design decision below for why the =MTStyle.step=/=token= helpers are not used. Zoomed-out density looks like the old clusterer; the interaction differs, see below.

**Inline layer styling — 1.3.0's setPaintProperty race

SDK 1.3.0's =MTStyle.setPaintProperty=/=setLayoutProperty= are fire-and-forget (even the Await variants only await dispatch), while addLayer suspends on an isSourceLoaded round trip or parks the add in a queue drained at the next ON_IDLE. Any property call issued after addLayer dispatches its JS before the layer exists on the JS map; MapLibre throws Layer <id> does not exist, and the WebView swallows it three ways (null evaluate callback, no console hook, not a map.on('error') event). Verified against the 1.3.0 sources (MTStyle.kt + StylableWorker.kt + WebViewExecutor.kt + assets): the layers were rendering at GL defaults — 5px black circle-color and an empty text field (no counts) — exactly what the device showed. The bridge-readiness gate can't help (the page IS ready; only the layer ordering is wrong). So radius/color/text paint+layout properties are typed fields on =MTCircleLayer=/=MTSymbolLayer= that @Serializable into the layer JSON addLayer sends atomically, plus a white circle-stroke-width for contrast on any base style; the step expressions are built with MTExpression.step over point_count and attached through StyleValue.Expression. The inline approach is dispatch-agnostic and survived the 2026-10-16 SDK 2.2.1 upgrade unchanged.

**Tap: exact feature hit-testing (SDK 2.2.1 queryRenderedFeatures)

Two versions of this existed under SDK 1.3.0, which exposed no queryRenderedFeatures: a nearest-node-within-window projection heuristic, then a max-pairwise-spread blob classifier — the second one misclassified blobs whose members render ~100px apart as separate pins (cluster taps degraded back to random-member cards), and the first one swallowed every multi-member blob tap into a zoom. The 2026-10-16 upgrade to 2.2.1 restored the missing API, so the tap handler is now exact, heuristics deleted:

  • queryRenderedFeatures(project(tap), [CIRCLE_LAYER]) — the GL map hit-tests the rendered circle geometry (paint-aware: the dots' own radius is the touch surface).

  • nothing under the tap — background: dismiss any open card.

  • payload features (nodeId properties from the source GeoJSON): one → the details card; several (co-located singles above clusterMaxZoom, or stacked pins) → the member-list card.

  • a cluster feature (point_count present, no payload) — easeTo centers on the tap and steps the zoom +2 (capped at 16) so the blob splits into members; tap again if it re-merges one step later.

The web-side map keeps proper popup/anchor behavior; the phone side now matches the old MapCompose interaction exactly: counts, cluster-expanding zooms, and the member list for the un-splittable blobs. Camera save/restore still runs off ON_MOVE_END.

**Camera state persistence

Same semantics as before (save on dispose was replaced by ON_MOVE_END saves — dispose can miss async bridge reads): (latitude, longitude, zoom) in AppPreferences; auto-fit to the marker bounds only on first load when no saved state exists.

**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 six state flows: locationNodes, isLoading, selectedNode, selectedCluster, 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(). The two selection flows are mutually exclusive (selectNode clears selectedCluster and vice versa): the node card shows one pin's details, the cluster card lists the members of a co-located blob.

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 _selectedCluster = MutableStateFlow<List<LocationNode>?>(null)
    val selectedCluster: StateFlow<List<LocationNode>?> = _selectedCluster.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 (lat, lon, zoom).
     */
    fun getMapCameraState(): Triple<Double, Double, Double>? {
        return appPreferences.getMapCameraState()
    }

    /**
     * Save map camera state for restoration.
     */
    fun saveMapCameraState(latitude: Double, longitude: Double, zoom: Double) {
        appPreferences.setMapCameraState(latitude, longitude, zoom)
    }

    /**
     * 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). Dismisses any co-located
     * cluster selection first — the two cards are mutually exclusive.
     */
    fun selectNode(node: LocationNode?) {
        _selectedNode.value = node
        _selectedCluster.value = null
    }

    /**
     * Select a co-located cluster (for the member-list card). Dismisses any
     * single-node selection.
     */
    fun selectCluster(nodes: List<LocationNode>?) {
        _selectedCluster.value = nodes
        _selectedNode.value = null
    }

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

MapViewModelTest

Selection-state behavior: the node card and the cluster card must stay mutually exclusive, and deselect clears whichever is open. Pure StateFlow plumbing — JVM-side (app test conventions).

kotlin#+name: map-viewmodel-test:tangle ../app/src/test/kotlin/computer/whatthefuck/arcology/app/viewmodel/MapViewModelTest.kt
package computer.whatthefuck.arcology.app.viewmodel

import computer.whatthefuck.arcology.app.data.AppPreferences
import computer.whatthefuck.arcology.app.testutils.RoamRepositoryTestDouble
import computer.whatthefuck.arcology.domain.GeoCoordinate
import computer.whatthefuck.arcology.domain.OrgNode
import io.kotest.matchers.shouldBe
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class MapViewModelTest {

    private val testDispatcher = StandardTestDispatcher()
    private lateinit var viewModel: MapViewModel

    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
        viewModel = MapViewModel(
            repository = RoamRepositoryTestDouble(),
            gpxExportServiceFactory = { error("export is out of scope for selection tests") },
            appPreferences = mockk(relaxed = true)
        )
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `selecting a node dismisses the cluster selection and vice versa`() = runTest {
        val a = LocationNode(
            node = OrgNode(id = "a", file = "f.org", level = 2, position = 0),
            coordinate = GeoCoordinate(44.0, -123.0)
        )
        val b = LocationNode(
            node = OrgNode(id = "b", file = "f.org", level = 2, position = 0),
            coordinate = GeoCoordinate(44.0, -123.0)
        )

        viewModel.selectCluster(listOf(a, b))
        viewModel.selectedCluster.value shouldBe listOf(a, b)
        viewModel.selectedNode.value shouldBe null

        viewModel.selectNode(a)
        viewModel.selectedNode.value shouldBe a
        viewModel.selectedCluster.value shouldBe null
    }

    @Test
    fun `deselect clears whichever card is open`() = runTest {
        val a = LocationNode(
            node = OrgNode(id = "a", file = "f.org", level = 2, position = 0),
            coordinate = GeoCoordinate(44.0, -123.0)
        )

        viewModel.selectNode(a)
        viewModel.selectNode(null)
        viewModel.selectedNode.value shouldBe null
        viewModel.selectedCluster.value shouldBe null

        viewModel.selectCluster(listOf(a))
        viewModel.selectCluster(null)
        viewModel.selectedNode.value shouldBe null
        viewModel.selectedCluster.value shouldBe null
    }
}

LocationMapScreen Composable

LocationMapScreen manages: the delegate wiring (install gate, camera save/restore, tap handling), runtime locate-me permission flow, marker source installation from filtered nodes, and overlay UI (filter/export buttons, tag filter chips, locate-me FAB, node detail card).

The screen's cluster layers + style resolution live in the shared MtMapStyle below; there is no per-node Compose marker rendering — everything is GL-side.

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 android.location.Location
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.Share
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.AppPreferences
import computer.whatthefuck.arcology.app.map.MtMapStyle
import computer.whatthefuck.arcology.app.viewmodel.LocationNode
import computer.whatthefuck.arcology.app.viewmodel.MapViewModel
import com.maptiler.maptilersdk.MTConfig
import com.maptiler.maptilersdk.events.MTEvent
import com.maptiler.maptilersdk.location.MTLocationManager
import com.maptiler.maptilersdk.location.MTLocationManagerDelegate
import com.maptiler.maptilersdk.map.LngLat
import com.maptiler.maptilersdk.map.MTMapOptions
import com.maptiler.maptilersdk.map.MTMapViewController
import com.maptiler.maptilersdk.map.MTMapView
import com.maptiler.maptilersdk.map.MTMapViewDelegate
import com.maptiler.maptilersdk.map.style.MTMapReferenceStyle
import com.maptiler.maptilersdk.map.options.MTCameraOptions
import com.maptiler.maptilersdk.map.options.MTEventLevel
import com.maptiler.maptilersdk.map.options.MTFitBoundsOptions
import com.maptiler.maptilersdk.map.options.MTPaddingOptions
import com.maptiler.maptilersdk.map.types.MTBounds
import com.maptiler.maptilersdk.map.types.MTData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject

mapscreen-composable

kotlin#+name: mapscreen-composable

/** Delegate wiring the SDK's event stream to the screen's state. Holds nothing
 * composable-side directly: callbacks close over stable ViewModel/scope refs,
 * and the volatile node list is pushed in via [updateNodes] on recomposition so
 * the remember{} block doesn't stale-capture filteredNodes. The camera reads
 * are suspend bridge calls, so this owns a scope to launch them from.
 *
 * Bridge-timing rule: the Kotlin =MTStyle= proxy goes non-null BEFORE the
 * WebView's JS `map` object exists — any style call dispatched early throws
 * `map is not defined` / `map.addSource is not a function` on the JS side and
 * silently vanishes on the Kotlin side (caught nowhere, retried never). The
 * gate starts on the SDK's own events and probes `isSourceLoaded` with a
 * retry loop; its lifecycle logs under [TAG] ("ArcologyMap") because the SDK
 * swallows its own constructor failures invisibly. */
private const val TAG = "ArcologyMap"

private class LocationMapDelegate(
    val controller: MTMapViewController,
    private val scope: CoroutineScope,
    private val onSaveCamera: (latitude: Double, longitude: Double, zoom: Double) -> Unit,
    private val onTap: (LngLat) -> Unit,
    private val onLocating: (Boolean) -> Unit
) : MTMapViewDelegate, MTLocationManagerDelegate {

    var nodes: List<LocationNode> = emptyList()
    var fitOnFirstLoad = false

    private var mapReady = false
    private var installInFlight = false

    override fun onMapViewInitialized() {
        // The bridge page finished loading — events now flow. Logged so an
        // on-device absence of this line localizes the failure to WebView
        // init itself.
        android.util.Log.i(TAG, "onMapViewInitialized")
    }

    override fun onEventTriggered(event: MTEvent, data: MTData?) {
        android.util.Log.i(TAG, "event: $event")
        when (event) {
            // The gate starts on the SDK's OWN events (ON_READY / ON_LOAD) —
            // both can only fire after the page's `window.map` exists, so the
            // first probe never races page init (racing it prints one
            // `map is not defined` uncaught per early attempt).
            MTEvent.ON_READY, MTEvent.ON_LOAD -> {
                scope.launch {
                    if (!mapReady) installWhenStyleLoaded()
                    if (mapReady && event == MTEvent.ON_LOAD && fitOnFirstLoad && nodes.isNotEmpty()) {
                        fitOnFirstLoad = false
                        MtMapStyle.boundsOf(nodes)?.let { bounds ->
                            controller.fitBounds(bounds, MTFitBoundsOptions(
                                padding = MTPaddingOptions(
                                    left = 64.0, top = 64.0, right = 64.0, bottom = 240.0
                                ),
                                maxZoom = 16.0
                            ))
                        }
                    }
                }
            }
            MTEvent.ON_MOVE_END -> {
                // Suspend bridge reads — off the delegate callback thread.
                scope.launch {
                    try {
                        val center = controller.getCenter()
                        val zoom = controller.getZoom()
                        onSaveCamera(center.lat, center.lng, zoom)
                    } catch (e: Exception) {
                    }
                }
            }
            MTEvent.ON_TAP -> data?.coordinate?.let(onTap)
            else -> {}
        }
    }

    /** Reinstall the clustered point layers with fresh node data. Stashes
     * only until the SDK's first event starts the gate — pre-init dispatches
     * are the exact `map is not defined` window we're avoiding. */
    fun updateNodes(newNodes: List<LocationNode>) {
        android.util.Log.i(TAG, "updateNodes: n=${newNodes.size} mapReady=$mapReady")
        nodes = newNodes
        if (mapReady) { // live bridge → install directly (re-install path)
            scope.launch { installLayers() }
        }
    }

    /** Timer backstop for the readiness gate (see the screen's
     * LaunchedEffect): starts the gate even when the SDK's events never
     * reach us. */
    fun onMountTimerFired() {
        if (!mapReady) scope.launch { installWhenStyleLoaded() }
    }

    /** Single-flight gate: wait for the bridge's JS `map` object, then
     * install. The probe is `isSourceLoaded` on an id that is never added —
     * a *definitive* `false` can only be produced by a live bridge answering
     * `map.isSourceLoaded` (pre-map attempts throw `map is not defined` JS
     * side and resolve null/throw Kotlin side, caught + retried). 2.2.1
     * also exposes isStyleLoaded, but the probe never trips before the map
     * object exists, which IS the property this gate needs. */
    fun installWhenStyleLoaded() {
        if (installInFlight) return
        installInFlight = true
        scope.launch {
            try {
                var attempts = 0
                while (!mapReady && attempts < 90) {
                    attempts++
                    try {
                        val style = controller.style
                        android.util.Log.i(
                            TAG,
                            "gate attempt $attempts: style=$style"
                        )
                        if (style?.isSourceLoaded(MtMapStyle.MAP_READY_PROBE) == false) {
                            mapReady = true
                            android.util.Log.i(TAG, "gate: probe definitive false → mapReady")
                        }
                    } catch (e: Exception) {
                        android.util.Log.i(
                            TAG,
                            "gate attempt $attempts: threw ${e::class.java.simpleName}: ${e.message}"
                        )
                    }
                    if (!mapReady) kotlinx.coroutines.delay(250)
                }
                if (mapReady) {
                    installLayers()
                } else {
                    android.util.Log.i(TAG, "gate FAILED after 90 attempts")
                    failed = true
                }
            } finally {
                installInFlight = false
            }
        }
    }

    /** True when the gate exhausts its attempts without a live-bridge probe
     * answer — surfaced on-screen so a fully-silent SDK death is not just a
     * blank canvas and nothing else. */
    var failed by mutableStateOf(false)

    private fun installLayers() {
        val style = controller.style ?: return
        MtMapStyle.installClusteredPoints(style, MtMapStyle.pointsGeoJson(nodes))
    }

    // LocationManager delegate (locate-me FAB):
    override fun didUpdateLocation(location: Location) {
        onLocating(false)
        val lngLat = LngLat(location.longitude, location.latitude)
        controller.setCenter(lngLat)
        controller.setZoom(11.0)
    }

    override fun didFailWithError(error: Throwable) {
        onLocating(false)
    }
}

@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun LocationMapScreen(
    viewModel: MapViewModel = koinViewModel(),
    onNodeSelected: (nodeId: String) -> Unit
) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    val appPreferences: AppPreferences = koinInject()

    val locationNodes by viewModel.locationNodes.collectAsState()
    val isLoading by viewModel.isLoading.collectAsState()
    val selectedNode by viewModel.selectedNode.collectAsState()
    val selectedCluster by viewModel.selectedCluster.collectAsState()
    val filterTags by viewModel.filterTags.collectAsState()
    val availableTags by viewModel.availableTags.collectAsState()

    val filteredNodes = remember(locationNodes, filterTags) {
        viewModel.getFilteredNodes()
    }

    var showFilterSheet by remember { mutableStateOf(false) }
    var isLocating by remember { mutableStateOf(false) }

    // Missing key = the map cannot exist (all tiles come from MapTiler Cloud);
    // point the user at Settings instead of rendering a blank GL canvas.
    val apiKey = remember {
        appPreferences.getMapTilerApiKey().also { if (it.isNotBlank()) MTConfig.apiKey = it }
    }
    if (apiKey.isBlank()) {
        Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
            Card(modifier = Modifier.fillMaxWidth()) {
                Column(modifier = Modifier.padding(24.dp)) {
                    Text("No MapTiler key", style = MaterialTheme.typography.titleMedium)
                    Spacer(Modifier.height(8.dp))
                    Text(
                        "The map screens render from MapTiler Cloud. Set your API key " +
                            "(and optionally a map style) in Settings → Map Display.",
                        style = MaterialTheme.typography.bodyMedium
                    )
                }
            }
        }
        return
    }

    val mapStylePref = remember { appPreferences.getMapStyle() }
    val referenceStyle = remember(mapStylePref, apiKey) { MtMapStyle.resolveReference(mapStylePref, apiKey) }
    val styleVariant = remember(mapStylePref) { MtMapStyle.resolveVariant(mapStylePref) }
    // The exact style the bridge receives — catches any resolution drift
    // between what the user typed in Settings and what reaches the SDK.
    android.util.Log.i(
        TAG,
        "style resolved: ${referenceStyle.getName()} custom=${referenceStyle.isCustom()}" +
            (referenceStyle as? MTMapReferenceStyle.CUSTOM)?.url?.let { " $it" }.orEmpty()
    )
    val savedCamera = remember { viewModel.getMapCameraState() }

    val controller = remember { MTMapViewController(context) }
    val delegate = remember(controller) {
        LocationMapDelegate(
            controller = controller,
            scope = scope,
            onSaveCamera = { lat, lon, zoom -> viewModel.saveMapCameraState(lat, lon, zoom) },
            onTap = { tapped ->
                scope.launch {
                    // Exact hit-test via the bridge (SDK 2.2.1
                    // queryRenderedFeatures): the GL map answers what is
                    // actually under the touch — cluster features carry
                    // point_count, singles carry the payload nodeId/title.
                    val tapPoint = runCatching { controller.project(tapped) }.getOrNull()
                        ?: return@launch
                    val features = runCatching {
                        MtMapStyle.parseTapFeatures(
                            controller.queryRenderedFeatures(tapPoint, listOf(MtMapStyle.CIRCLE_LAYER))
                        )
                    }.getOrDefault(emptyList())
                    val members = features
                        .mapNotNull { feature ->
                            feature.nodeId?.let { id ->
                                filteredNodes.firstOrNull { locationNode ->
                                    locationNode.node.id == id
                                }
                            }
                        }
                    when {
                        features.isEmpty() -> viewModel.selectNode(null)
                        members.size == 1 -> viewModel.selectNode(members.first())
                        members.isNotEmpty() -> viewModel.selectCluster(members)
                        else -> {
                            // Cluster feature(s): step the zoom in so the
                            // blob splits into its members.
                            val zoom = runCatching { controller.getZoom() }.getOrNull()
                                ?: return@launch
                            runCatching {
                                controller.easeTo(
                                    MTCameraOptions(
                                        center = tapped,
                                        zoom = minOf(zoom + 2.0, 16.0)
                                    )
                                )
                            }
                        }
                    }
                }
            },
            onLocating = { isLocating = it }
        ).also { controller.delegate = it }
    }
    // Node changes flow into the delegate after composition, so the
    // remember{} block never stale-captures filteredNodes.
    LaunchedEffect(filteredNodes) { delegate.updateNodes(filteredNodes) }

    // Locate-me: SDK LocationManager, permissions requested by the launcher.
    val locationManager = remember(context) {
        MTLocationManager(context).also { it.delegate = delegate }
    }
    // Timer backstop for the gate: the SDK's own ON_READY/ON_LOAD should
    // start it, but a device where those events never cross the bridge
    // (observed during the swap debugging) would otherwise never install
    // layers. 2.5s is well past the console banner timestamp, so the first
    // probe lands on a live map with no JS noise.
    LaunchedEffect(Unit) {
        kotlinx.coroutines.delay(2500)
        delegate.onMountTimerFired()
    }
    val locationPermissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
            permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
        ) {
            isLocating = true
            locationManager.requestLocationOnce()
        }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        // Map. The options instance is remembered: MTMapOptions has identity
        // equals, and an inline instance hands the SDK a "changed" parameter
        // on every recomposition (nodes arrive, selection toggles…), which
        // re-runs initializeMap over itself — observed on-device as doubled
        // style-load cycles and a broken event bridge.
        val mapOptions = remember(savedCamera) {
            MTMapOptions(
                center = savedCamera?.let { (lat, lon, _) -> LngLat(lon, lat) },
                zoom = savedCamera?.third,
                eventLevel = MTEventLevel.ESSENTIAL
            )
        }
        MTMapView(
            referenceStyle = referenceStyle,
            styleVariant = styleVariant,
            options = mapOptions,
            controller = controller,
            modifier = Modifier.fillMaxSize()
        )

        if (delegate.failed) {
            Card(
                modifier = Modifier
                    .align(Alignment.Center)
                    .padding(16.dp)
            ) {
                Column(modifier = Modifier.padding(20.dp)) {
                    Text("Map failed to initialize", style = MaterialTheme.typography.titleMedium)
                    Spacer(Modifier.height(8.dp))
                    Text(
                        "The MapTiler bridge never went ready. Check Settings → Map Display " +
                            "(API key, style) and the device network, then reopen the tab.",
                        style = MaterialTheme.typography.bodySmall
                    )
                }
            }
        }

        // 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 (!locationManager.hasLocationPermission()) {
                    locationPermissionLauncher.launch(
                        arrayOf(
                            Manifest.permission.ACCESS_FINE_LOCATION,
                            Manifest.permission.ACCESS_COARSE_LOCATION
                        )
                    )
                } else {
                    isLocating = true
                    locationManager.requestLocationOnce()
                }
            },
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp)
                .padding(bottom = if (selectedNode != null || selectedCluster != 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)
            )
        }

        // Co-located cluster: member list card (a zoom cannot split these —
        // the members ARE the entries)
        selectedCluster?.let { members ->
            ClusterDetailsCard(
                members = members,
                onClose = { viewModel.selectCluster(null) },
                onOpen = onNodeSelected,
                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))
            }
        }
    }
}

MtMapStyle — shared MapTiler style resolution + GL point layers

Both map screens (this file's LocationMapScreen, and locationpicker.org) resolve the mapStyle preference through MtMapStyle, which mirrors the web style knob's semantics (maps/web.org): a reference name with optional -variant resolves through a fixed name map; a custom style — bare UUID, maptiler://<uuid>, or a full style.json URL — becomes MTMapReferenceStyle.CUSTOM(url) with the API key appended when absent; blank/garbage falls back to STREETS. It also owns the GeoJSON point payload and the two-layer cluster install (circle layer stepped on point_count + count-label symbol layer, both styled inline on the layer objects because 1.3.0's setPaintProperty dispatches before addLayer lands — see this file's inline-styling design decision; the point_count_abbreviated text is empty on unclustered singles — no cluster-vs-point filters needed), plus the tap-feature payload parser (=TapFeature=/=parseTapFeatures=) the tap handler feeds from queryRenderedFeatures (SDK 2.2.1).

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

import com.maptiler.maptilersdk.map.style.MTMapReferenceStyle
import com.maptiler.maptilersdk.map.style.MTMapStyleVariant
import com.maptiler.maptilersdk.map.style.MTStyle
import com.maptiler.maptilersdk.map.style.dsl.MTExpression
import com.maptiler.maptilersdk.map.style.dsl.MTFeatureKey
import com.maptiler.maptilersdk.map.style.dsl.MTTextToken
import com.maptiler.maptilersdk.map.style.dsl.PropertyValue
import com.maptiler.maptilersdk.map.style.dsl.StyleValue
import com.maptiler.maptilersdk.map.style.layer.circle.MTCircleLayer
import com.maptiler.maptilersdk.map.style.layer.symbol.MTSymbolLayer
import com.maptiler.maptilersdk.map.style.source.MTGeoJSONSource
import com.maptiler.maptilersdk.map.types.MTBounds
import computer.whatthefuck.arcology.app.viewmodel.LocationNode
import java.net.URL
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put

/**
 * MapTiler style resolution + GL point-layer plumbing shared by the map
 * screens. The `style` preference uses the same semantics as the web
 * ARCOLOGY_MAP `style=` knob (maps/web.org): reference name with optional
 * `-variant`, or a custom style (bare UUID / maptiler:// / full URL); blank
 * or unknown falls back to STREETS.
 */
object MtMapStyle {

    private val UUID_RE = Regex(
        "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
        RegexOption.IGNORE_CASE
    )

    const val SOURCE_ID = "arcology-points"
    const val CIRCLE_LAYER = "arcology-points-circle"
    const val COUNT_LAYER = "arcology-points-count"

    /** The source's clusterRadius, in CSS px — the same unit
     * controller.project() and the GL clusterer work in. */
    const val CLUSTER_RADIUS_PX = 60.0

    /** isSourceLoaded probe id that is never added: a definitive `false`
     * answer proves the bridge's JS `map` object exists (the bridge-timing
     * gate in both map-screen delegates). */
    const val MAP_READY_PROBE = "arcology-map-ready-probe"

    /**
     * Feature under the map screen's tap, from
     * =controller.queryRenderedFeatures=. A cluster feature carries
     * =point_count=; a single point feature carries the =nodeId=/=title=
     * payload the source GeoJSON ships.
     */
    @kotlinx.serialization.Serializable
    data class TapFeature(
        val nodeId: String? = null,
        val title: String? = null,
        @kotlinx.serialization.SerialName("point_count") val pointCount: Int? = null
    )

    /** queryRenderedFeatures returns feature envelopes — the tap payload
     * lives in =properties=. */
    @kotlinx.serialization.Serializable
    private data class TapFeatureEnvelope(val properties: TapFeature? = null)

    private val lenientJson = kotlinx.serialization.json.Json {
        ignoreUnknownKeys = true
        isLenient = true
    }

    /**
     * Parse the JSON feature array a queryRenderedFeatures bridge call
     * returns. Null/blank/garbage → empty list; never throws — the tap
     * flow treats empty as "nothing under the tap".
     */
    fun parseTapFeatures(json: String?): List<TapFeature> = try {
        if (json.isNullOrBlank()) {
            emptyList()
        } else {
            lenientJson
                .decodeFromString<List<TapFeatureEnvelope>>(json)
                .mapNotNull { it.properties }
        }
    } catch (e: Exception) {
        emptyList()
    }

    private val NAMED = mapOf(
        "streets" to MTMapReferenceStyle.STREETS,
        "satellite" to MTMapReferenceStyle.SATELLITE,
        "outdoor" to MTMapReferenceStyle.OUTDOOR,
        "winter" to MTMapReferenceStyle.WINTER,
        "dataviz" to MTMapReferenceStyle.DATAVIZ,
        "bright" to MTMapReferenceStyle.BRIGHT,
        "topo" to MTMapReferenceStyle.TOPO,
        "toner" to MTMapReferenceStyle.TONER,
        "backdrop" to MTMapReferenceStyle.BACKDROP,
        "openstreetmap" to MTMapReferenceStyle.OPENSTREETMAP,
        "aquarelle" to MTMapReferenceStyle.AQUARELLE,
        "landscape" to MTMapReferenceStyle.LANDSCAPE,
        "ocean" to MTMapReferenceStyle.OCEAN
    )

    private val VARIANTS = mapOf(
        "default" to MTMapStyleVariant.DEFAULT_VARIANT,
        "dark" to MTMapStyleVariant.DARK,
        "light" to MTMapStyleVariant.LIGHT,
        "night" to MTMapStyleVariant.NIGHT,
        "pastel" to MTMapStyleVariant.PASTEL,
        "shiny" to MTMapStyleVariant.SHINY,
        "topographique" to MTMapStyleVariant.TOPOGRAPHIQUE,
        "lite" to MTMapStyleVariant.LITE,
        "lines" to MTMapStyleVariant.LINES,
        "background" to MTMapStyleVariant.BACKGROUND,
        "vivid" to MTMapStyleVariant.VIVID
    )

    private fun isCustom(s: String): Boolean =
        UUID_RE.matches(s) || s.startsWith("http", ignoreCase = true) ||
            s.startsWith("maptiler://", ignoreCase = true)

    /** The style.json URL for a custom style spec, key appended when absent. */
    fun customUrl(spec: String, apiKey: String?): URL? {
        val base = when {
            spec.startsWith("maptiler://", ignoreCase = true) ->
                "https://api.maptiler.com/maps/${spec.substringAfter("maptiler://", "")}/style.json"
            UUID_RE.matches(spec) ->
                "https://api.maptiler.com/maps/$spec/style.json"
            spec.startsWith("http", ignoreCase = true) ->
                spec
            else -> return null
        }.takeIf { it.isNotEmpty() && !it.endsWith("maps//style.json") } ?: return null
        val withKey = if (apiKey != null && !base.contains("key=")) {
            base + (if (base.contains('?')) "&" else "?") + "key=$apiKey"
        } else base
        return runCatching { URL(withKey) }.getOrNull()
    }

    fun resolveReference(raw: String?, apiKey: String? = null): MTMapReferenceStyle {
        val s = raw?.trim().orEmpty()
        if (s.isEmpty()) return MTMapReferenceStyle.STREETS
        if (isCustom(s)) {
            return customUrl(s, apiKey)?.let { MTMapReferenceStyle.CUSTOM(it) }
                ?: MTMapReferenceStyle.STREETS
        }
        val parts = s.replace('_', '-').split('-').filter { it.isNotEmpty() }
        return parts.firstOrNull()?.let { NAMED[it.lowercase()] } ?: MTMapReferenceStyle.STREETS
    }

    fun resolveVariant(raw: String?): MTMapStyleVariant? {
        val s = raw?.trim().orEmpty()
        if (s.isEmpty() || isCustom(s)) return null
        val parts = s.replace('_', '-').split('-').filter { it.isNotEmpty() }
        if (parts.size < 2) return null
        return VARIANTS[parts[1].lowercase()]
    }

    /** FeatureCollection GeoJSON for the clustered point source. */
    fun pointsGeoJson(nodes: List<LocationNode>): String =
        buildJsonObject {
            put("type", "FeatureCollection")
            put("features", buildJsonArray {
                nodes.forEach { locationNode ->
                    add(buildJsonObject {
                        put("type", "Feature")
                        put("geometry", buildJsonObject {
                            put("type", "Point")
                            put("coordinates", buildJsonArray {
                                add(JsonPrimitive(locationNode.coordinate.longitude))
                                add(JsonPrimitive(locationNode.coordinate.latitude))
                            })
                        })
                        put("properties", buildJsonObject {
                            put("nodeId", locationNode.node.id)
                            put("title", locationNode.node.title ?: "Location")
                        })
                    })
                }
            })
        }.toString()

    fun boundsOf(nodes: List<LocationNode>): MTBounds? {
        if (nodes.isEmpty()) return null
        var minLat = Double.MAX_VALUE
        var maxLat = -Double.MAX_VALUE
        var minLon = Double.MAX_VALUE
        var maxLon = -Double.MAX_VALUE
        nodes.forEach {
            minLat = minOf(minLat, it.coordinate.latitude)
            maxLat = maxOf(maxLat, it.coordinate.latitude)
            minLon = minOf(minLon, it.coordinate.longitude)
            maxLon = maxOf(maxLon, it.coordinate.longitude)
        }
        return MTBounds(minLon, minLat, maxLon, maxLat)
    }

    /** =step= expression over =point_count=: =default= below the first stop,
     * each stop's value from that count upward. Returned as a raw
     * =PropertyValue= so the JVM tests can assert its code form. */
    fun countStep(
        default: PropertyValue,
        stops: List<Pair<Double, PropertyValue>>
    ): PropertyValue =
        MTExpression.step(MTExpression.get(MTFeatureKey.POINT_COUNT), default, stops)

    /** Radius, singles-first: 14px pins; blobs grow 22px (>=6 members) and
     * 30px (>=20). */
    fun circleRadiusStep(): StyleValue = StyleValue.Expression(
        countStep(
            PropertyValue.Num(14.0),
            listOf(6.0 to PropertyValue.Num(22.0), 20.0 to PropertyValue.Num(30.0))
        )
    )

    /** Fill ramp: blue singles, orange blobs (>=6), red crowds (>=20) —
     * saturated colors so the white stroke and white count labels keep
     * contrast against any base style. */
    fun circleColorStep(): StyleValue = StyleValue.Expression(
        countStep(
            PropertyValue.Color(0xFF1E88E5.toInt()),
            listOf(
                6.0 to PropertyValue.Color(0xFFFB8C00.toInt()),
                20.0 to PropertyValue.Color(0xFFE53935.toInt())
            )
        )
    )

    /** Count-label token: resolves to the member count on clusters and the
     * empty string on unclustered singles — no cluster-vs-point filters
     * needed. */
    val COUNT_LABEL = MTTextToken.POINT_COUNT_ABBREVIATED.token

    /**
     * Two layer install over the clustered point source. ALL paint/layout
     * properties ride the layer objects (typed DSL fields that serialize
     * into the layer JSON addLayer sends) — SDK 1.3.0's setPaintProperty is
     * fire-and-forget while addLayer defers/queues its own JS, so any
     * post-hoc property call lands BEFORE the layer exists on the JS map;
     * MapLibre throws `layer does not exist`, the WebView swallows it, and
     * the layers render at GL defaults (5px black circles, no count labels).
     * Reinstall-safe: drops the previous source + layers first.
     */
    fun installClusteredPoints(style: MTStyle, geoJson: String): Boolean {
        style.removeLayerById(COUNT_LAYER)
        style.removeLayerById(CIRCLE_LAYER)
        style.removeSourceById(SOURCE_ID)
        return try {
            style.addSource(MTGeoJSONSource(SOURCE_ID, geoJson).apply {
                isCluster = true
                clusterRadius = CLUSTER_RADIUS_PX
                clusterMaxZoom = 14.0
            })
            style.addLayer(MTCircleLayer(CIRCLE_LAYER, SOURCE_ID).apply {
                radius = circleRadiusStep()
                color = circleColorStep()
                strokeWidth = 2.5
                strokeColor = 0xFFFFFFFF.toInt()
            })
            style.addLayer(MTSymbolLayer(COUNT_LAYER, SOURCE_ID).apply {
                textField = COUNT_LABEL
                textSize = 13.0
                textAllowOverlap = true
                textColor = StyleValue.Color(0xFFFFFFFF.toInt())
            })
            true
        } catch (e: Exception) {
            style.removeLayerById(COUNT_LAYER)
            style.removeLayerById(CIRCLE_LAYER)
            style.removeSourceById(SOURCE_ID)
            false
        }
    }
}
kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/map/MtMapStyle.kt:noweb yes
<<mt-map-style>>

MtMapStyleTest

The style resolver and GeoJSON/payload builders are pure code — tested JVM-side (app test conventions).

kotlin#+name: mt-map-style-test:tangle ../app/src/test/kotlin/computer/whatthefuck/arcology/app/map/MtMapStyleTest.kt
package computer.whatthefuck.arcology.app.map

import computer.whatthefuck.arcology.app.viewmodel.LocationNode
import computer.whatthefuck.arcology.domain.GeoCoordinate
import computer.whatthefuck.arcology.domain.OrgNode
import com.maptiler.maptilersdk.map.style.MTMapReferenceStyle
import com.maptiler.maptilersdk.map.style.dsl.StyleValue
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.double
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

class MtMapStyleTest {

    private val UUID = "c912ffc8-2360-487a-973b-59d037fb15b8"

    @Test
    fun `blank and unknown styles fall back to streets`() {
        assertEquals(true, MtMapStyle.resolveReference("") === MTMapReferenceStyle.STREETS)
        assertEquals(true, MtMapStyle.resolveReference(null) === MTMapReferenceStyle.STREETS)
        assertEquals(true, MtMapStyle.resolveReference("not-a-real-style") === MTMapReferenceStyle.STREETS)
        assertNull(MtMapStyle.resolveVariant("not-a-real-style"))
    }

    @Test
    fun `named reference resolves case-insensitively`() {
        assertEquals(true, MtMapStyle.resolveReference("outdoor") === MTMapReferenceStyle.OUTDOOR)
        assertEquals(true, MtMapStyle.resolveReference("OUTDOOR") === MTMapReferenceStyle.OUTDOOR)
        assertEquals(true, MtMapStyle.resolveReference("ocean") === MTMapReferenceStyle.OCEAN)
    }

    @Test
    fun `dash variant resolves against the style`() {
        assertEquals("dark", MtMapStyle.resolveVariant("streets-dark")?.name?.lowercase())
        // No variant on a bare name:
        assertNull(MtMapStyle.resolveVariant("streets"))
        // Variants never apply to custom styles:
        assertNull(MtMapStyle.resolveVariant(UUID))
    }

    @Test
    fun `bare uuid becomes a custom style url with the key`() {
        val style = MtMapStyle.resolveReference(UUID, apiKey = "key-1")
        assertEquals(true, style is MTMapReferenceStyle.CUSTOM)
        val url = (style as MTMapReferenceStyle.CUSTOM).url.toString()
        assertEquals("https://api.maptiler.com/maps/$UUID/style.json", url.substringBefore("?"))
        assertTrue(url.endsWith("key=key-1"))
        // Without a key: no key param, CUSTOM still (the SDK would fail to
        // load tiles, but the resolution stays honest).
        val bare = MtMapStyle.resolveReference(UUID, null)
        assertEquals(true, bare is MTMapReferenceStyle.CUSTOM)
        assertTrue(!(bare as MTMapReferenceStyle.CUSTOM).url.toString().contains("key="))
    }

    @Test
    fun `maptiler uri and full urls normalize to custom`() {
        val viaUri = MtMapStyle.resolveReference("maptiler://$UUID", "key-1") as MTMapReferenceStyle.CUSTOM
        assertEquals("https://api.maptiler.com/maps/$UUID/style.json", viaUri.url.toString().substringBefore("?"))

        val passedThrough = "https://api.maptiler.com/maps/$UUID/style.json?key=embedded"
        val viaUrl = MtMapStyle.resolveReference(passedThrough, "key-2") as MTMapReferenceStyle.CUSTOM
        // Key already embedded: resolution must not append a second key.
        assertEquals(passedThrough, viaUrl.url.toString())
    }

    @Test
    fun `pointsGeoJson carries lng lat order and node properties`() {
        val node = LocationNode(
            node = OrgNode(id = "wpt-1", file = "f.org", level = 2, position = 0, title = "Camp A"),
            coordinate = GeoCoordinate(44.04, -123.10),
            tags = listOf("tag")
        )
        val doc = Json.parseToJsonElement(MtMapStyle.pointsGeoJson(listOf(node))).jsonObject
        assertEquals("FeatureCollection", doc["type"]?.jsonPrimitive?.content)
        val feature = doc["features"]!!.jsonArray.first().jsonObject
        val coordinates = feature["geometry"]!!.jsonObject["coordinates"]!!.jsonArray
            .map { it.jsonPrimitive.double }
        assertEquals(listOf(-123.10, 44.04), coordinates)
        assertEquals(
            "wpt-1",
            feature["properties"]!!.jsonObject["nodeId"]!!.jsonPrimitive.content
        )
    }

    @Test
    fun `boundsOf spans the node set`() {
        val a = LocationNode(
            node = OrgNode(id = "a", file = "f.org", level = 2, position = 0),
            coordinate = GeoCoordinate(44.0, -123.0)
        )
        val b = LocationNode(
            node = OrgNode(id = "b", file = "f.org", level = 2, position = 1),
            coordinate = GeoCoordinate(45.0, -124.0)
        )
        val bounds = MtMapStyle.boundsOf(listOf(a, b))!!
        assertEquals(-124.0, bounds.southwest.lng, 1e-9)
        assertEquals(44.0, bounds.southwest.lat, 1e-9)
        assertEquals(-123.0, bounds.northeast.lng, 1e-9)
        assertEquals(45.0, bounds.northeast.lat, 1e-9)
        assertNull(MtMapStyle.boundsOf(emptyList()))
    }

    @Test
    fun `cluster styling rides inline point_count step expressions`() {
        val radius = (MtMapStyle.circleRadiusStep() as StyleValue.Expression).expr.asCode()
        assertTrue(radius.contains("\"step\""))
        assertTrue(radius.contains("\"point_count\""))
        assertTrue(radius.contains("14.0"))
        assertTrue(radius.contains("22.0"))
        assertTrue(radius.contains("30.0"))

        val color = (MtMapStyle.circleColorStep() as StyleValue.Expression).expr.asCode()
        assertTrue(color.contains("#1E88E5"))
        assertTrue(color.contains("#FB8C00"))
        assertTrue(color.contains("#E53935"))

        assertEquals("{point_count_abbreviated}", MtMapStyle.COUNT_LABEL)
    }

    @Test
    fun `tap feature payloads parse out of query envelopes`() {
        val clusterJson = "[{\"type\":\"Feature\",\"geometry\":{}," +
            "\"layer\":{\"id\":\"arcology-points-circle\"}," +
            "\"properties\":{\"cluster_id\":3,\"point_count\":12,\"point_count_abbreviated\":\"12\"}}]"
        val clusters = MtMapStyle.parseTapFeatures(clusterJson)
        assertEquals(1, clusters.size)
        assertEquals(12, clusters[0].pointCount)
        assertNull(clusters[0].nodeId)

        val singlesJson = "[{\"properties\":{\"nodeId\":\"wpt-1\",\"title\":\"Camp\"}}," +
            "{\"properties\":{\"nodeId\":\"wpt-2\",\"title\":\"Cairn\"}}]"
        val singles = MtMapStyle.parseTapFeatures(singlesJson)
        assertEquals(2, singles.size)
        assertEquals("wpt-1", singles[0].nodeId)
        assertEquals("Camp", singles[0].title)
        assertNull(singles[0].pointCount)
    }

    @Test
    fun `tap parsing is null and garbage safe`() {
        assertEquals(0, MtMapStyle.parseTapFeatures(null).size)
        assertEquals(0, MtMapStyle.parseTapFeatures("").size)
        assertEquals(0, MtMapStyle.parseTapFeatures("not json at all").size)
    }
}

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
        )
    }
}

mapscreen-clustercard

Member list for a co-located cluster: every member's title with its own Open button, height-capped and scrollable.

kotlin#+name: mapscreen-clustercard

@Composable
private fun ClusterDetailsCard(
    members: List<LocationNode>,
    onClose: () -> Unit,
    onOpen: (nodeId: String) -> 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 = "${members.size} locations here",
                    style = MaterialTheme.typography.titleMedium
                )
                IconButton(onClick = onClose) {
                    Icon(Icons.Default.Close, contentDescription = "Close")
                }
            }

            Spacer(modifier = Modifier.height(4.dp))

            Column(
                modifier = Modifier
                    .heightIn(max = 240.dp)
                    .verticalScroll(rememberScrollState())
            ) {
                members.forEach { member ->
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.SpaceBetween,
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Text(
                            text = member.node.title ?: "Untitled",
                            style = MaterialTheme.typography.bodyMedium,
                            modifier = Modifier.weight(1f),
                            maxLines = 2,
                            overflow = TextOverflow.Ellipsis
                        )
                        TextButton(onClick = { onOpen(member.node.id) }) {
                            Text("Open")
                        }
                    }
                }
            }
        }
    }
}

MapScreen Assembly

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

<<mapscreen-composable>>

<<mapscreen-nodedetails>>

<<mapscreen-clustercard>>

Map Platform Components

One shared helper class in app.map: MtMapStyle (above) — MapTiler style resolution (named reference styles + variants, custom UUID/URL pass-through mirroring the web `style=` knob), the clustered-point GeoJSON payload, MTBounds fitting, and the two-layer GL cluster install used by both the map screen and the location picker. The MapCompose-era MapUtils (Web Mercator normalization) and OsmTileProvider (raster tile fetch/cache) died with the 2026-10-10 swap — MapTiler Cloud hosts the tiles now.

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