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 (
nodeIdproperties from the source GeoJSON): one → the details card; several (co-located singles aboveclusterMaxZoom, or stacked pins) → the member-list card.a cluster feature (
point_countpresent, no payload) —easeTocenters 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.
/**
* 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.
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).
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
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.koinInjectmapscreen-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).
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
}
}
}<<mt-map-style>>MtMapStyleTest
The style resolver and GeoJSON/payload builders are pure code — tested JVM-side (app test conventions).
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
@Composable
private fun NodeDetailsCard(
locationNode: LocationNode,
onClose: () -> Unit,
onOpen: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = locationNode.node.title ?: "Untitled",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
if (locationNode.node.outlinePath.isNotEmpty()) {
Text(
text = locationNode.node.outlinePath.joinToString(" > "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = "%.4f, %.4f".format(
locationNode.coordinate.latitude,
locationNode.coordinate.longitude
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (locationNode.tags.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
locationNode.tags.forEach { tag ->
SuggestionChip(
onClick = { },
label = { Text(tag, style = MaterialTheme.typography.labelSmall) }
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onOpen,
modifier = Modifier.fillMaxWidth()
) {
Text("Open")
}
}
}
}mapscreen-clustermarker
@Composable
private fun ClusterMarker(count: Int) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.border(2.dp, MaterialTheme.colorScheme.onPrimary, CircleShape),
contentAlignment = Alignment.Center
) {
Text(
text = if (count > 99) "99+" else count.toString(),
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.labelMedium
)
}
}mapscreen-clustercard
Member list for a co-located cluster: every member's title with its own Open button, height-capped and scrollable.
@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
<<map-viewmodel>><<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
MapViewModelwithRoamRepository,KmlExportService,AppPreferencesApp Data Layer —
AppPreferencesfor camera state persistence,LocationServicefor GPSSearch Screen — cross-mode wiring via
FindScreencontainerModels & Repository —
RoamRepository.getNodesWithLocation()for geo queries