Status: IMPLEMENTED 2026-10-10. Third surface of the maps/index.org design: publish a page whose GEO_COORDS descendants render as markers on a MapTiler-hosted MapLibre map, with marker↔heading anchoring both directions and a no-JS fallback list. The phone-side (import-export.org) was allowed to age first, but the scope decision flipped early: the web surface ships now.
Publish a GPX-export subtree as its own page?
Publish a subtree (say a folder holding all my "Siuslaw & Smith River watershed" POI) as a page with its own ARCOLOGY_KEY, embedding a web map that shows the POI which are sub-keys of it.
Yes, and it needs less new machinery than it looks like. The publisher already has the whole stack: getNodesWithLocation() returns OrgNode + GeoCoordinate off the shared repo, CrossDomainLinkResolver.resolveNodeId() turns a node id into =/route#anchor= for subheadings under a published ancestor, and routes key off ARCOLOGY_KEY through RouteTable. A map page is then: a node carries ARCOLOGY_KEY, its descendants carrying GEO_COORDS + a :ID: become markers, and the server template context gains mapDataJson / mapEntries consumed by a map.peb template slotted into the ARCOLOGY_PAGE_TEMPLATE dropdown the capture screen already exposes. Opt-in via ARCOLOGY_PAGE_TEMPLATE: map rather than auto-embedding, so a journal page with one stray GEO_COORDS node doesn't load map JS by surprise. Nothing here is OsmAnd-specific — the gpx artifacts are the phone-side projection, the org database is what the web map renders; that decoupling is what makes the three surfaces independent.
Persistence split
The GPX writer emits metadata extensions and the reader lands them on the Metadata object (implements GpxExtensions, same text-only flat-key rules). So osmand:arcology_key / osmand:arcology_feed in <metadata><extensions> round-trip through our export→import forever. But OsmAnd's own favorites save rebuilds the file from groups only (FavouritesFileHelper.asGpxFile) — nothing copies the loaded metadata — so file-level extensions do not survive OsmAnd's own save cycle, while wpt-level ones do. Design consequence: the org files are the persistence layer for ARCOLOGY_KEY and kin — the publisher routes on their file-properties and never on the gpx. mapMarkers likewise come from the DB, not from any gpx.
Tile provider: MapTiler + MapTiler SDK JS
Decision (2026-10-10): MapTiler, not OpenFreeMap/AWS Terrain — the opening line of this file's settled design always said MapTiler (free-tier account + API key, want 3D + satellite); the "woven-in recommendation" paragraph that favored OpenFreeMap + AWS terrain was the wrong reading of that line and is superseded. MapTiler free tier: ~100k loads/mo, no card; MapStyle.SATELLITE and hybrid MapStyle.HYBRID (satellite + labels) cover the satellite want; terrain-rgb raster-DEM under the SDK's terrain: option covers the 3D want.
Client: *MapTiler SDK JS* (BSD, extends MapLibre GL JS and exposes all its features) loaded from their CDN, NOT vendored — the old "no third-party script origin" concern is moot: a MapTiler-backed map inherently talks to api.maptiler.com for every tile, so there is no third-party-origin-free variant of this feature. Pin the version in map-peb and bump deliberately:
https://cdn.maptiler.com/maptiler-sdk-js/v4.2.0/maptiler-sdk.umd.min.jshttps://cdn.maptiler.com/maptiler-sdk-js/v4.2.0/maptiler-sdk.css
Session-based usage accounting comes with the SDK by default (map sessions, not per-tile requests) — the favorable billing model on the free tier.
SDK specifics used by arcology-map-js (pinned against docs.maptiler.com 2026-10-10):
maptilersdk.config.apiKeykey= thennew maptilersdk.Map({container, style, center: [lng, lat], zoom, terrain, terrainControl})MapStylereference styles are proxied objects with variant lookup —MapStyle.STREETS,.SATELLITE,.HYBRID,.OUTDOOR,.WINTER,.BASE,.DATAVIZ..., variants likeMapStyle.STREETS.DARK; nonexistent variants fall back to the style's default inside the proxycustom styles (the UUID the style editor assigns at Publish) pass as plain strings, three accepted forms: bare UUID,
maptiler://<uuid>, or a fullstyle.jsonURL (optionally =?key=-suffixed);styleToStylenormalizes them and injectsconfig.apiKey, and an unresolvable style falls back to the default with a warning rather than hard-failing (README +sdk-js/examples/custom-map, pinned 2026-10-10)new maptilersdk.Marker({element}).setLngLat([lng,lat]).setPopup(popup)/.addTo(map)— markers are DOM nodes whenelementis passed, so a marker is literally an =<a class="poi-marker" data-heading-id>=new maptilersdk.Popup({offset, maxWidth}).setHTML(html)terrain: true/terrainControl: truemap options enable 3D terrain;map.enableTerrain()/disableTerrain()/hasTerrain()exist programmatically
The older survey of keyless alternatives (OpenFreeMap styles, AWS terrain terrarium, CARTO raster, PMTiles upgrade path, Leaflet fallback) lives in the Tile-server capability matrix below and stays useful if the MapTiler account ever dies; the osm.org iframe embed remains a zero-JS link-out at best (no custom popups, no marker→heading anchors).
Tile-server capability matrix (satellite vector 3D)
Surveyed 2026-10-07; satellite = aerial/space imagery, vector = raster vs vector tiles, 3D = terrain DEM availability (MapLibre raster-dem → setTerrain). Retained as the fallback survey — NOT the chosen path (see the MapTiler decision above).
| source | key | satellite | vector | 3D | notes |
|---|
-----------------+-----|----------------------------------------------------------------------------------------------------------------------------------------------------|
| OpenFreeMap | none | no | yes | no | styles positron/liberty/dark/fiord/3d only; 3D here means a style, not terrain. Self-hostable. |
| CARTO basemaps | none | no | raster only | no | the keyless raster fallback |
| AWS Terrain Tiles (Mapzen) | none | no | no (raster DEM) | yes | Terrarium PNG over plain s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png; OSS "open data", no SLA. Add as second raster-dem source. |
| Mapterhorn | none | no | no (raster DEM) | yes | Terrarium in WebP PMTiles, single-file self-host, global to z12; the production-grade keyless terrain option |
| Esri World Imagery | no (keyless URL, account terms) | yes | raster only | no | best free sub-meter satellite US/EU; plain tile URL works but terms gate it |
| NASA GIBS WMTS | none | yes (daily, coarse) | raster only | no | daily MODIS/VIIRS/Landsat-adjacent, good for wildfire/flood layers, not basemap-sharp |
| MapTiler | yes (free tier, no card) | yes | yes | yes | full sweep: streets+satellite vector, terrain-rgb, quantized-mesh 3D, self-host downloads. Chosen. |
| Stadia Maps | yes (free dev tier) | yes | yes | yes | Alidade vector styles + terrarium DEM on CDN |
| Thunderforest | yes | no | raster/outdoors | no | Outdoors style only; no imagery |
ARCOLOGY_MAP knobs
Page-level property, same precedence as ARCOLOGY_PAGE_TEMPLATE (a heading :PROPERTIES: drawer on the route node, else the file's file-level properties). Encoded the same way as the gis layer's OSMAND_EXTENSIONS property — backslash-escaped =key=value= pairs joined by ;, decoded with GpxOrgMapping.decodeExtensions so the escaping rules exist in exactly one place:
:ARCOLOGY_MAP: key=<MAPTILER KEY>; style=outdoor; terrain=on
:ARCOLOGY_MAP: key=<KEY>; style=streets-dark; center=44.04,-123.1; zoom=9Placement constraint (smoke-test finding, 2026-10-10): both ARCOLOGY_PAGE_TEMPLATE and ARCOLOGY_MAP must ride a :PROPERTIES: drawer — the file-level drawer before #+TITLE: (the archive.org convention) for file-level pages, or the page heading's drawer for section-level pages. A bare #+ARCOLOGY_MAP: / #+ARCOLOGY_PAGE_TEMPLATE: keyword line drives =ArcologyPublishingPlugin='s route insertion but never reaches file_properties, so the page routes correctly and then silently renders page.peb with no map. The keys knob reads below say "property" meaning drawer property.
| knob | meaning | default |
|---|---|---|
key | MapTiler API key override for this page. Resolution order: this knob → the ARCOLOGY_MAPTILER_KEY process env var → "". Prefer the env var and leave the knob unset — the published org files then never carry the key. Client-side keys are public by design either way; restrict the key to the published site's domains in the MapTiler console. | $ARCOLOGY_MAPTILER_KEY |
style | a MapTiler MapStyle reference name, lowercase, optional -light/-dark/... variant (outdoor, streets-dark, satellite, hybrid...) — or a custom style: the bare UUID MapTiler assigns at Publish time (=style=1a2b3c4d-...=), maptiler://<uuid>, or a full style.json URL, all passed through as strings for the SDK's styleToStyle to normalize with config.apiKey | streets |
center | lat,lon — validated by GeoCoordinate.parse and re-emitted normalized | fit bounds of markers |
zoom | initial zoom, honored only together with center | fit bounds of markers |
terrain | on/true/yes enables the 3D terrain and the terrain toggle control | off |
center without zoom (or vice versa) starts from fitBounds over the marker set with maxZoom 16 — a lone zoom would anchor the view nowhere meaningful.
Key hygiene: the keywords sidebar
=SidebarService='s keywords panel dumps every =ARCOLOGY_= file property* into the rendered sidebar — an ARCOLOGY_MAP file property would surface its key knob as page-visible text. The panel filter excludes ARCOLOGY_MAP by name (web/server.org). Placements: a heading-level :ARCOLOGY_MAP: never reaches the keywords panel (that panel reads file properties only) and never renders as body text, so both placements are safe once the filter is in.
Better still, don't put the key in org at all: export ARCOLOGY_MAPTILER_KEY into the serve process environment (the deployment module's environmentFile, sops-rendered) and the page's knob can drop key entirely — the builder falls back to the env var, and the org source never has a secret in it.
Markers: the rendered subtree
The marker set = GEO_COORDS nodes with :ID: inside the page's rendered subtree, consistent with the export section's anchor rule: the renderer's slug fallback keeps bare in-page anchors working, but markers key on the deterministic id (models.org) because that's the handle that survives the OsmAnd round trip.
Mechanics (map-pages-config / map-pages-builder):
repository.getNodesWithLocation()— one query returns every(OrgNode, GeoCoordinate)candidate off the sharednode_propertiestable; filter to the entries' files. This is the query the original design note named, and it is bounded by the geotagged population, not the file size.Sub-tree membership the same way
CrossDomainLinkResolveralready asks it:repository.getNodeAncestors(node.id)containsentry.nodeId(the ancestors rows are self-inclusive, so an entry node carryingGEO_COORDSitself is also its own marker). The parser'sparentNodeIdis not used —RoamRepositoryImpldoes not hydrate it, thenode_ancestorstable is the only DB-backed hierarchy.EXCLUDE_TAGSfiltering matches the renderer's elision: a =noexport=/=NOEXPORT=/=ARCHIVE= tagged heading never becomes a marker or list entry.href comes from
LinkResolver.resolveNodeId(nodeId)→ a same-site =/route#nodeId= is an in-page scroll target, a cross-site =//other-site/route#nodeId= is a popup link out (the resolver returns absolute URLs for other sites). Fallback ="#nodeId"= when the resolver comes up empty.
Ordering follows document position, so the no-JS list reads like the outline. The page's own prose renders after the map (htmlContent), so a map page is a normal published page that happens to lead with a map. Config precedence uses the first entry of the path (the timestamp-oldest, same one resolveTemplate reads).
The JSON blob
One =<script type="application/json" class="arcology-map-data">= carries config + markers together (the noweb-ref-map pattern — data for the script, not the template). It is emitted with |raw, so the builder neutralizes </ (→ <\/) so a POI title containing </script> cannot break out of the tag; <\/ is a legal JSON escape and decodes back to </ in the browser. Marker payload: {id, lat, lon, altitude?, title?, href}.
Marker ↔ heading anchoring
Both directions off the one blob, no framework (the ~60-line arcology-map-js):
Markers are DOM anchors (pass
element:), so a marker is literally =<a class="poi-marker" data-heading-id="${id}">=; a same-page click is a plainscrollIntoViewplushistory.replaceStateto#<ID>; cross-site hrefs get default anchor navigation.The anchor target exists because the renderer already emits the org
:ID:as the heading'sid(OrgHtmlRenderer.kt—headingId(section)else slugified title).POI-list titled links (the no-JS list's
<a href data-poi-id>) are click-anchored too: with JS theypreventDefaultand center the map on the POI instead of navigating — the row'sgeo:link stays the OsmAnd handoff and the marker popup is the way out to cross-site pages.Reverse direction: the same blob keyed by id feeds a delegated document-level click handler — a rendered heading whose
idis in the marker map smooth-scrolls to the heading and =map.flyTo=s its coords.Popups are title + link +
geo:href, built client-side (v1; the user call was "title + link only"). Full org-body popups with inline crushed-attachment =<img>=s are the documented follow-up; OsmAnd's desc HTML keeps theosmand:arcology_orgrestore channel in the meantime.
Without JS the page still carries the POIs as a <ul> of links — the map is progressive enhancement and the list is the content — and each entry gets a geo:lat,lon link, so the camping-story handoff (phone opens OsmAnd) is one tap from the same page.
Attachments & photos
Org-side photos survive any number of round trips because they never enter the gpx. Convention: child heading tagged :ATTACH: under the POI heading holding file: links — indexing/resolution/rendering already built and tested (FlowFileIndexer → published_attachments, ArcologyAttachmentPlugin, LinkResolver.resolveAttachment(), ATTACHMENT_IMAGE_EXTENSIONS render as <img>). The v1 popup shows only title + link, so attachment photos in marker popups need the body-rendering follow-up; until then the photos are visible the moment the reader scrolls to the heading itself.
Implementation
MapPages.kt — config, builder, JSON
Tangles to src/jvmMain/kotlin/computer/whatthefuck/arcology/publishing/MapPages.kt. Config parse reuses GpxOrgMapping.decodeExtensions so OSMAND_EXTENSIONS escaping has one home. parseRaw is fail-soft: a bogus knob is skipped, never an error — a typo in zoom must not blank an otherwise working map page.
package computer.whatthefuck.arcology.publishing
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.GeoCoordinate
import computer.whatthefuck.arcology.gis.GpxOrgMapping
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/** Knobs from the ARCOLOGY_MAP property (OSMAND_EXTENSIONS-style k=v encoding). */
data class MapPageConfig(
val key: String?,
val style: String,
val center: String?,
val zoom: Double?,
val terrain: Boolean
) {
companion object {
const val MAP_PROPERTY = "ARCOLOGY_MAP"
val DEFAULT = MapPageConfig(key = null, style = "streets", center = null, zoom = null, terrain = false)
/**
* Fail-soft parse: a bogus knob is skipped, not an error. `center` is
* validated through GeoCoordinate.parse and re-emitted normalized so
* " 44.0 , -123.0 " round-trips as "44.0,-123.0" in the JSON blob.
*/
fun parseRaw(raw: String?): MapPageConfig {
if (raw.isNullOrBlank()) return DEFAULT
val knobs = GpxOrgMapping.decodeExtensions(raw)
val center = knobs["center"]
?.takeIf { it.isNotBlank() }
?.let { GeoCoordinate.parse(it) }
?.let { "${it.latitude},${it.longitude}" }
val terrain = knobs["terrain"]?.trim()?.lowercase() in setOf("on", "true", "yes")
return MapPageConfig(
key = knobs["key"]?.trim()?.takeIf { it.isNotEmpty() },
style = knobs["style"]?.trim()?.takeIf { it.isNotEmpty() } ?: DEFAULT.style,
center = center,
zoom = knobs["zoom"]?.trim()?.takeIf { it.isNotEmpty() }?.toDoubleOrNull(),
terrain = terrain
)
}
}
}
@Serializable
data class MapMarkerJson(
val id: String,
val lat: Double,
val lon: Double,
val altitude: Double? = null,
val title: String? = null,
val href: String
)
@Serializable
data class MapPageData(
val key: String,
val style: String,
val center: String? = null,
val zoom: Double? = null,
val terrain: Boolean = false,
val markers: List<MapMarkerJson> = emptyList()
)
/**
* The no-JS fallback list: the map is progressive enhancement, this list is
* the content. One row per marker, in document order, each row an in-page or
* cross-site anchor plus a geo: link for the OsmAnd handoff.
*/
data class MapEntry(
val id: String,
val title: String?,
val href: String,
val geoHref: String,
val coords: String
)
/** What lands in the PageModel: the raw JSON blob + the fallback rows. */
class MapPageContext(val json: String, val entries: List<MapEntry>)private val mapPageJson = Json { encodeDefaults = true }
/** Neutralize </ so a title cannot close the enclosing <script> tag early. */
internal fun escapeMapJson(json: String): String = json.replace("</", "<\\/")
/**
* Build the map-page context for the route entries sharing one published
* path. Returns null when the rendered subtree holds no markers — the page
* then behaves exactly like it would under page.peb (minus the map).
*
* The MapTiler key resolves in this order: the ARCOLOGY_MAP `key=` knob
* (org drawer property), then the ARCOLOGY_MAPTILER_KEY process env var,
* else "". The env var is the recommended carrier — it keeps the key out
* of the org files entirely — while the knob remains for per-page
* overrides. `envKey` is a parameter so tests inject explicit values and
* stay hermetic.
*/
suspend fun buildMapPageContext(
repository: RoamRepository,
resolver: LinkResolver,
entries: List<RouteEntry>,
envKey: String? = System.getenv("ARCOLOGY_MAPTILER_KEY")?.takeIf { it.isNotBlank() }
): MapPageContext? {
val firstEntry = entries.firstOrNull() ?: return null
val effectiveEnvKey = envKey?.takeIf { it.isNotBlank() }
val rawMap: String? = repository.getHeadingProperty(firstEntry.nodeId, MapPageConfig.MAP_PROPERTY)
?: repository.getFileProperties(firstEntry.file)[MapPageConfig.MAP_PROPERTY]
val config = MapPageConfig.parseRaw(rawMap)
val entriesById = entries.associateBy { it.nodeId }
val candidates = repository.getNodesWithLocation()
.filter { (node, _) -> entries.any { it.file == node.file } }
.sortedBy { (node, _) -> node.position }
if (candidates.isEmpty()) return null
val ancestorsCache = mutableMapOf<String, List<String>>()
val tagsByNode = repository.getTagsByNodes(candidates.map { it.first.id })
val excludeNodeIds = tagsByNode
.filter { (_, tags) -> tags.any { it in EXCLUDE_TAGS } }
.keys
val markers = mutableListOf<MapMarkerJson>()
val noJs = mutableListOf<MapEntry>()
for ((node, coord) in candidates) {
if (node.id in excludeNodeIds) continue
val pageEntry = entriesById.values.firstOrNull { entry ->
entry.file == node.file && ancestorsCache
.getOrPut(node.id) { repository.getNodeAncestors(node.id) }
.contains(entry.nodeId)
} ?: continue
val href = resolver.resolveNodeId(node.id) ?: "#${node.id}"
val latLon = "${coord.latitude},${coord.longitude}"
markers.add(MapMarkerJson(
id = node.id,
lat = coord.latitude,
lon = coord.longitude,
altitude = coord.altitude,
title = node.title,
href = href
))
noJs.add(MapEntry(
id = node.id,
title = node.title,
href = href,
geoHref = "geo:$latLon",
coords = latLon
))
}
if (markers.isEmpty()) return null
val data = MapPageData(
key = config.key ?: effectiveEnvKey ?: "",
style = config.style,
center = config.center,
// zoom without center would anchor the view nowhere; fitBounds instead
zoom = if (config.center != null) config.zoom else null,
terrain = config.terrain,
markers = markers
)
return MapPageContext(escapeMapJson(mapPageJson.encodeToString(data)), noJs)
}map.peb — the map page template
Extends app.peb. Map + JSON blob on top, then the page's own prose (the rendered route entry bodies via htmlContent, sidenotes after — same shape as page.peb). No sidebar panels: the map page reads as map + outline + prose, and backlinks/tags stay discoverable from sibling pages. Center/zoom-less maps start from fitBounds over the markers (handled in arcology-map-js); =#arcology-map= is viewport-relative with a floor so phones still show something substantial. The template-level CSS follows the wide.peb/topic.peb inline <style> convention — no separate stylesheet, no web/templates.org change.
{% extends "app.peb" %}
{% block title %}{{ headTitle }}{% endblock %}
{% block extra_head %}
{% for feed in feeds %}
<link rel="alternate" type="application/atom+xml" href="{{ feed.url }}" title="{{ feed.title }}"/>
{% endfor %}
{% if allowCrawl %}
<meta name="robots" content=""/>
{% else %}
<meta name="robots" content="noarchive noimageindex noindex nofollow"/>
{% endif %}
<link href="https://cdn.maptiler.com/maptiler-sdk-js/v4.2.0/maptiler-sdk.css" rel="stylesheet"/>
<script src="https://cdn.maptiler.com/maptiler-sdk-js/v4.2.0/maptiler-sdk.umd.min.js" defer></script>
<script src="/static/arcology-map.js" defer></script>
<style>
#arcology-map { width: 100%; height: 60vh; min-height: 320px; margin: 0 0 1em 0; }
.poi-marker {
display: block; width: 14px; height: 14px; border-radius: 50%;
background: #8b0000; border: 2px solid #fff;
box-shadow: 0 0 2px rgba(0,0,0,.5); cursor: pointer;
}
.poi-list { padding-left: 1.2em; }
</style>
{% endblock %}
{% block content %}
{% if mapDataJson %}
<script type="application/json" class="arcology-map-data">{{ mapDataJson | raw }}</script>
{% endif %}
<div id="arcology-map"></div>
{% if mapEntries | length > 0 %}
<ul class="poi-list">
{% for poi in mapEntries %}
<li>
<a href="#" data-poi-id="{{ poi.id }}">{{ poi.title | default("untitled") }}</a>
— <a href="{{ poi.geoHref }}">{{ poi.coords }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
<main>{{ htmlContent | raw }}</main>
{{ sidenotesHtml | raw }}
{% endblock %}arcology-map.js — markers, anchoring, popups
Tangles to src/jvmMain/resources/static/arcology-map.js. Deferred, so the SDK script (also deferred, declared before it) is present by the time this runs. Behavior:
Read the
arcology-map-dataJSON; bail quietly when absent/empty/malformed or when the SDK failed to load — a map page degrades to its no-JS list.Build the map: style knob → custom-style pass-through first (bare UUID /
maptiler:/// fullstyle.jsonURL are returned as strings for the SDK'sstyleToStyle; see the tile-provider section's custom-style bullet), elseMapStyle[REF][VARIANT]proxy resolution (unknown reference falls back to STREETS with a console warning; nonexistent variants fall back inside the SDK's proxy);center"lat,lon" → SDK =center=[lng, lat]= withzoom; elsefitBoundsover the marker set onloadwithmaxZoom16.One =<a class="poi-marker">= DOM anchor per entry attached to the map;
setPopupgives the title + link +geo:popup on click. Same-page hrefs (^/) getpreventDefault+ smoothscrollIntoView+ =history.replaceState(#id)=; cross-site hrefs get default anchor navigation so the popup link works too.Click anchoring, one delegated document-level handler for three surfaces: a
.poi-listtitled link (data-poi-id) is =preventDefault=-ed and centers the map on its POI (scroll + =history.replaceState(#id)= only when the heading is on this page); a rendered heading whoseidis a marker id smooth-scrolls and flies the map; the marker =<a>=s keep their own same-page-scroll/cross-site-nav behavior from item 3.
(function () {
"use strict";
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
.replace(/"/g, """);
}
var UUID_STYLE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function resolveStyle(name) {
var s = String(name || "").trim();
// Custom styles pass through as strings: a bare UUID (the id MapTiler
// assigns when a designer style is published), a maptiler:// URI, or a
// full style.json URL. The SDK's styleToStyle normalizes all three and
// injects config.apiKey itself; only named reference styles take the
// MapStyle proxy path below.
if (UUID_STYLE_RE.test(s) || /^(https?:\/\/|maptiler:\/\/)/i.test(s)) return s;
var styles = window.maptilersdk && window.maptilersdk.MapStyle;
if (!styles) return null;
var parts = s.replace(/_/g, "-").split("-").filter(Boolean);
if (parts.length === 0) return styles.STREETS;
var ref = styles[parts[0].toUpperCase()];
if (!ref) {
console.warn("arcology-map: unknown MapStyle reference", name);
return styles.STREETS;
}
if (parts.length > 1) {
var variant = ref[parts[1].toUpperCase()];
if (variant) return variant;
}
return ref;
}
function init() {
var script = document.querySelector('script.arcology-map-data[type="application/json"]');
var container = document.getElementById("arcology-map");
if (!script || !container) return;
if (typeof window.maptilersdk === "undefined") {
console.warn("arcology-map: maptilersdk not loaded");
return;
}
var data;
try {
data = JSON.parse(script.textContent || "{}");
} catch (e) {
console.warn("arcology-map: malformed map data", e);
return;
}
var markers = data.markers || [];
if (!markers.length) return;
maptilersdk.config.apiKey = data.key || "";
var options = {
container: container,
style: resolveStyle(data.style) || maptilersdk.MapStyle.STREETS
};
if (data.terrain) {
options.terrain = true;
options.terrainControl = true;
}
var staticView = false;
if (data.center && data.zoom != null) {
var center = String(data.center).split(",").map(Number);
options.center = [center[1], center[0]]; // knob is "lat,lon"; SDK wants [lng, lat]
options.zoom = Number(data.zoom);
staticView = true;
}
var map = new maptilersdk.Map(options);
var bounds = null;
var byId = {};
markers.forEach(function (m) {
byId[m.id] = m;
var lat = Number(m.lat);
var lon = Number(m.lon);
if (bounds === null) {
bounds = { minLat: lat, maxLat: lat, minLon: lon, maxLon: lon };
} else {
bounds.minLat = Math.min(bounds.minLat, lat);
bounds.maxLat = Math.max(bounds.maxLat, lat);
bounds.minLon = Math.min(bounds.minLon, lon);
bounds.maxLon = Math.max(bounds.maxLon, lon);
}
var el = document.createElement("a");
el.className = "poi-marker";
el.dataset.headingId = m.id;
el.href = m.href;
el.title = m.title || "";
el.setAttribute("aria-label", m.title || "map marker");
var sameSite = m.href.indexOf("/") === 0;
el.addEventListener("click", function (ev) {
if (!sameSite) return; // cross-site POI: popup links out, default nav
ev.preventDefault();
var target = document.getElementById(m.id);
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
history.replaceState(null, "", "#" + m.id);
});
var popupHtml =
"<strong>" + escapeHtml(m.title || "untitled") + "</strong><br/>" +
'<a href="' + escapeHtml(m.href) + '">open</a>' +
' · <a href="geo:' + lat + "," + lon + '">geo</a>';
new maptilersdk.Marker({ element: el })
.setLngLat([lon, lat])
.setPopup(new maptilersdk.Popup({ offset: 18, maxWidth: "280px" }).setHTML(popupHtml))
.addTo(map);
});
if (!staticView && bounds !== null) {
map.on("load", function () {
map.fitBounds(
[[bounds.minLon, bounds.minLat], [bounds.maxLon, bounds.maxLat]],
{ padding: 60, maxZoom: 16, duration: 0 }
);
});
}
// Click-anchoring, three surfaces, one delegated handler:
// - a .poi-list titled link centers the map on its POI (the row's geo:
// link stays the OsmAnd handoff; the marker popup stays the
// navigation path for cross-site points),
// - a rendered heading whose id is a marker scrolls the heading into
// view and flies the map there,
// - the map's own DOM marker <a>s handle their own clicks: same-page
// hrefs scroll to the heading, cross-site hrefs navigate out.
document.addEventListener("click", function (ev) {
var t = ev.target;
var m = null;
var link = t && t.closest ? t.closest(".poi-list a[data-poi-id]") : null;
if (link) {
m = byId[link.getAttribute("data-poi-id")] || null;
if (!m) return; // not a marker row — fall through to default nav
ev.preventDefault(); // centering IS the navigation for this link
var poiTarget = document.getElementById(m.id);
// if (poiTarget) {
// poiTarget.scrollIntoView({ behavior: "smooth", block: "start" });
// if (link.getAttribute("href").indexOf("/") === 0) {
// history.replaceState(null, "", "#" + m.id);
// }
// }
} else {
var heading = t && t.closest ? t.closest("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") : null;
if (heading) {
m = byId[heading.id] || null;
if (m) {
var headTarget = document.getElementById(heading.id);
if (headTarget) {
headTarget.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
}
}
if (m) {
map.flyTo({
center: [Number(m.lon), Number(m.lat)],
zoom: Math.max(map.getZoom(), 14),
speed: 1.2
});
}
});
}
init();
})();Server wiring
Lives in web/server.org (that file owns PageModel and ArcologyServer):
PageModelgainsmapDataJson: String?null= andmapEntries: List<MapEntry>emptyList()= — defaulted, so theCachedPageenvelope and every existing constructor keep working. Markers are computed per request and deliberately NOT cached: the envelope stays the (html, sidenotes, headings) triple; the marker computation is cheap at POI scale (onegetNodesWithLocation+ ancestor lookups) and file-hash changes already force body re-renders.resolveTemplatemapsARCOLOGY_PAGE_TEMPLATE"map" →map.peb.servePathBody/servePathLocalhostBodycallbuildMapPageContextwhen the resolved template ismap.peb;modelToMapforwardsmapDataJson/mapEntriesto Pebble (filterValues { it !null }= already drops the null JSON so the template's{% if %}sees a clean absence).SidebarServiceexcludesARCOLOGY_MAPfrom the keywords panel (see Key hygiene)./arcology/node/{nodeId}preview renders with whateverARCOLOGY_PAGE_TEMPLATEsays but never builds markers (the fake route carries no site/path), so node previews of map pages are body-only. Deliberate.
Tests
MapPagesTest lives in src/jvmTest/.../publishing/ and follows ArcologyServerRouteLookupTest's conventions: EmptyRoamRepository subclassed to serve canned location/ancestor/property data, CrossDomainLinkResolver built over a hand-made route index sharing the same repository instance — the resolver walks getNodeAncestors through its own private repo handle, so the fake repo must feed both the builder and the resolver or the same-site/cross-site hrefs collapse to the =#id= fallback. Assertions decode the emitted JSON back into MapPageData via the @Serializable declaration instead of string-matching, except for the escapeMapJson case which exists to pin raw-string behavior.
package computer.whatthefuck.arcology.publishing
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.GeoCoordinate
import computer.whatthefuck.arcology.domain.OrgNode
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class MapPagesTest {
private val pageEntry = RouteEntry(
nodeId = "20261010T100000", site = "garden", path = "siuslaw",
file = "camping.org", title = "Siuslaw", timestamp = "20261010T100000",
isDraft = false, isArchived = false
)
private fun node(
id: String, file: String = "camping.org", position: Int = 0,
title: String? = null, props: Map<String, String> = emptyMap()
): OrgNode = OrgNode(
id = id, file = file, level = 2, position = position,
title = title, properties = props
)
private fun coord(lat: Double, lon: Double) = GeoCoordinate.parse("$lat,$lon")!!
private fun repo(
nodesWithLocation: List<Pair<OrgNode, GeoCoordinate>>,
ancestors: Map<String, List<String>> = emptyMap(),
headingProps: Map<String, Map<String, String?>> = emptyMap(),
fileProps: Map<String, Map<String, String?>> = emptyMap(),
tags: Map<String, List<String>> = emptyMap()
): RoamRepository = object : EmptyRoamRepository() {
override suspend fun getNodesWithLocation() = nodesWithLocation
override suspend fun getNodeAncestors(nodeId: String) = ancestors[nodeId] ?: emptyList()
override suspend fun getHeadingProperty(nodeId: String, key: String): String? =
headingProps[nodeId]?.get(key)
override suspend fun getFileProperties(file: String) = fileProps[file] ?: emptyMap()
override suspend fun getTagsByNodes(nodeIds: List<String>) =
nodeIds.mapNotNull { id -> tags[id]?.let { id to it } }.toMap()
}
private fun resolver(
repo: RoamRepository,
routeIndex: Map<String, RouteEntry> = mapOf(pageEntry.nodeId to pageEntry),
domainMap: DomainMap = DomainMap.fromMap(mapOf("garden" to "arcology.garden"))
): CrossDomainLinkResolver = CrossDomainLinkResolver(
routeIndex = routeIndex,
domainMap = domainMap,
repository = repo,
currentSite = "garden",
localhost = false
)
private val testJson = Json { ignoreUnknownKeys = true }
private fun decode(context: MapPageContext): MapPageData =
testJson.decodeFromString(MapPageData.serializer(), context.json) @Test
fun `markers come from the rendered subtree only`() = runBlocking {
val inside = node("wpt-1", position = 1, title = "Camp A")
val outside = node("wpt-2", file = "other.org", position = 2, title = "Elsewhere")
val repo = repo(
nodesWithLocation = listOf(
outside to coord(47.0, -119.0),
inside to coord(44.0, -123.0)
),
ancestors = mapOf("wpt-1" to listOf("wpt-1", pageEntry.nodeId))
)
val ctx = assertNotNull(buildMapPageContext(repo, resolver(repo), listOf(pageEntry)))
val data = decode(ctx)
assertEquals(listOf("wpt-1"), data.markers.map { it.id })
assertEquals(1, ctx.entries.size)
assertEquals("/siuslaw#wpt-1", ctx.entries.first().href)
assertEquals("geo:44.0,-123.0", ctx.entries.first().geoHref)
assertEquals("44.0,-123.0", ctx.entries.first().coords)
}
@Test
fun `entry node itself is a marker via self-inclusive ancestors`() = runBlocking {
val self = node(pageEntry.nodeId, position = 0, title = "Base camp")
val repo = repo(
nodesWithLocation = listOf(self to coord(44.1, -123.1)),
ancestors = mapOf(pageEntry.nodeId to listOf(pageEntry.nodeId))
)
val ctx = assertNotNull(buildMapPageContext(repo, resolver(repo), listOf(pageEntry)))
assertEquals(listOf(pageEntry.nodeId), decode(ctx).markers.map { it.id })
}
@Test
fun `cross site subtree node gets absolute href`() = runBlocking {
val foreign = RouteEntry(
nodeId = "20261009T090000", site = "lionsrear", path = "woods",
file = "woods.org", title = "Woods", timestamp = null,
isDraft = false, isArchived = false
)
val routeIndex = mapOf(
pageEntry.nodeId to pageEntry,
foreign.nodeId to foreign
)
val domainMap = DomainMap.fromMap(
mapOf("garden" to "arcology.garden", "lionsrear" to "thelionsrear.com")
)
val trail = node("wpt-3", file = "woods.org", position = 1, title = "Trailhead")
val repo = repo(
nodesWithLocation = listOf(trail to coord(44.2, -122.9)),
ancestors = mapOf("wpt-3" to listOf("wpt-3", "20261009T090000"))
)
val ctx = assertNotNull(
buildMapPageContext(repo, resolver(repo, routeIndex, domainMap), listOf(foreign))
)
assertEquals("//thelionsrear.com/woods#wpt-3", ctx.entries.first().href)
}
@Test
fun `exclude-tagged heading never becomes a marker`() = runBlocking {
val archived = node("wpt-4", position = 3, title = "Secret spot")
val repo = repo(
nodesWithLocation = listOf(archived to coord(44.3, -123.2)),
ancestors = mapOf("wpt-4" to listOf("wpt-4", pageEntry.nodeId)),
tags = mapOf("wpt-4" to listOf("ARCHIVE"))
)
assertNull(buildMapPageContext(repo, resolver(repo), listOf(pageEntry)))
}
@Test
fun `config knobs parse from heading property over file property`() = runBlocking {
val marker = node("wpt-5", position = 1, title = "Knob camp")
val withHeading = repo(
nodesWithLocation = listOf(marker to coord(44.4, -123.3)),
ancestors = mapOf("wpt-5" to listOf("wpt-5", pageEntry.nodeId)),
headingProps = mapOf(
pageEntry.nodeId to mapOf("ARCOLOGY_MAP" to "key=abc; style=outdoor; terrain=on")
),
fileProps = mapOf("camping.org" to mapOf("ARCOLOGY_MAP" to "key=stale"))
)
val ctx = assertNotNull(buildMapPageContext(withHeading, resolver(withHeading), listOf(pageEntry)))
val data = decode(ctx)
assertEquals("abc", data.key)
assertEquals("outdoor", data.style)
assertTrue(data.terrain)
val fromFileOnly = repo(
nodesWithLocation = listOf(marker to coord(44.4, -123.3)),
ancestors = mapOf("wpt-5" to listOf("wpt-5", pageEntry.nodeId)),
fileProps = mapOf(
"camping.org" to mapOf("ARCOLOGY_MAP" to "key=filekey; center=44.0,-123.0; zoom=11")
)
)
val ctx2 = assertNotNull(buildMapPageContext(fromFileOnly, resolver(fromFileOnly), listOf(pageEntry)))
val data2 = decode(ctx2)
assertEquals("filekey", data2.key)
assertEquals("44.0,-123.0", data2.center)
assertEquals(11.0, data2.zoom)
}
@Test
fun `env key fills in when the knob is absent`() = runBlocking {
val marker = node("wpt-9", position = 1, title = "Env camp")
val repo = repo(
nodesWithLocation = listOf(marker to coord(44.8, -123.7)),
ancestors = mapOf("wpt-9" to listOf("wpt-9", pageEntry.nodeId))
)
val ctx = assertNotNull(buildMapPageContext(repo, resolver(repo), listOf(pageEntry), envKey = "envkey"))
assertEquals("envkey", decode(ctx).key)
val envAndKnob = repo(
nodesWithLocation = listOf(marker to coord(44.8, -123.7)),
ancestors = mapOf("wpt-9" to listOf("wpt-9", pageEntry.nodeId)),
fileProps = mapOf("camping.org" to mapOf("ARCOLOGY_MAP" to "key=knobkey"))
)
val ctx2 = assertNotNull(
buildMapPageContext(envAndKnob, resolver(envAndKnob), listOf(pageEntry), envKey = "envkey")
)
assertEquals("knobkey", decode(ctx2).key)
}
@Test
fun `blank env key is ignored like a missing one`() = runBlocking {
val marker = node("wpt-10", position = 1, title = "Blank env camp")
val repo = repo(
nodesWithLocation = listOf(marker to coord(44.9, -123.8)),
ancestors = mapOf("wpt-10" to listOf("wpt-10", pageEntry.nodeId))
)
val ctx = assertNotNull(buildMapPageContext(repo, resolver(repo), listOf(pageEntry), envKey = " "))
assertEquals("", decode(ctx).key)
}
@Test
fun `zoom without center is ignored and junk knobs fail soft`() = runBlocking {
val marker = node("wpt-6", position = 1, title = "Soft camp")
val repo = repo(
nodesWithLocation = listOf(marker to coord(44.5, -123.4)),
ancestors = mapOf("wpt-6" to listOf("wpt-6", pageEntry.nodeId)),
fileProps = mapOf(
"camping.org" to mapOf("ARCOLOGY_MAP" to "zoom=9; center=nope; style=")
)
)
val ctx = assertNotNull(buildMapPageContext(repo, resolver(repo), listOf(pageEntry)))
val data = decode(ctx)
assertNull(data.center)
assertNull(data.zoom)
assertEquals("streets", data.style)
assertFalse(data.terrain)
}
@Test
fun `no markers means no map context`() = runBlocking {
val repo = repo(
nodesWithLocation = listOf(node("wpt-7", file = "elsewhere.org") to coord(44.6, -123.5))
)
assertNull(buildMapPageContext(repo, resolver(repo), listOf(pageEntry)))
}
@Test
fun `map json neutralizes script closers`() = runBlocking {
val marker = node("wpt-8", position = 1, title = "Evil</script>spot")
val repo = repo(
nodesWithLocation = listOf(marker to coord(44.7, -123.6)),
ancestors = mapOf("wpt-8" to listOf("wpt-8", pageEntry.nodeId))
)
val ctx = assertNotNull(buildMapPageContext(repo, resolver(repo), listOf(pageEntry)))
assertFalse(ctx.json.contains("</script>"))
assertTrue(ctx.json.contains("<\\/script>"))
assertEquals("Evil</script>spot", decode(ctx).markers.first().title)
}
@Test
fun `MapPageConfig defaults and terrain spellings`() {
val default = MapPageConfig.parseRaw(null)
assertEquals("streets", default.style)
assertNull(default.key)
assertFalse(default.terrain)
assertTrue(MapPageConfig.parseRaw("terrain=YES").terrain)
assertTrue(MapPageConfig.parseRaw("terrain=on").terrain)
assertFalse(MapPageConfig.parseRaw("terrain=off").terrain)
assertEquals(9.0, MapPageConfig.parseRaw("zoom=9").zoom)
assertNull(MapPageConfig.parseRaw("zoom=bogus").zoom)
assertEquals("44.0,-123.0", MapPageConfig.parseRaw("center= 44.0 , -123.0 ").center)
assertNull(MapPageConfig.parseRaw("center=a,b").center)
}}Tests: server route integration
The route-level join lives in web/server.org's test assembly (ArcologyServerMapPageTest.kt): a map-template page with geotagged descendants must surface mapDataJson / mapEntries through servePathPublic, and a page.peb page must keep both fields empty — the wiring gate for map-pages-builder.
Tangle targets
src/jvmMain/kotlin/computer/whatthefuck/arcology/publishing/MapPages.kt← map-pages-config ∥ map-pages-buildersrc/jvmMain/resources/templates/map.peb← map-pebsrc/jvmMain/resources/static/arcology-map.js← arcology-map-jssrc/jvmTest/kotlin/computer/whatthefuck/arcology/publishing/MapPagesTest.kt← map-pages-test-prelude + map-pages-test-cases + map-pages-test-end
<<map-pages-config>>
<<map-pages-builder>><<map-pages-test-prelude>>
<<map-pages-test-cases>>
<<map-pages-test-end>>