Point the basemaps at a configurable tile server

a640fb2 · Gabriel Morell · 2026-08-28 21:11

26 files +432 -25
Message
{commit_body(@commit)}

Files changed

added CUSTOM_MAPS.md
+147 −0
@@ -0,0 +1,147 @@
1 +# Custom map tiles
2 +
3 +Every Leaflet map in room_sanctum draws its basemap from one tile server,
4 +chosen by configuration. Unset, it is CARTO's keyless greyscale basemaps —
5 +which is what every map used before this was configurable. Set, it is whatever
6 +server you point it at.
7 +
8 +The moving parts:
9 +
10 +| Where | What |
11 +| --- | --- |
12 +| `config/runtime.exs` | reads `TILE_*` from the environment |
13 +| `apps/room_sanctum/lib/room_sanctum/basemap.ex` | drops the blank keys, names the meta tags |
14 +| `<.basemap_meta />` in `CoreComponents`, in both root layouts | writes `<meta name="basemap-*">` into `<head>` |
15 +| `apps/room_sanctum/assets/js/leaflet/basemap.js` | resolves the source once per page, falls back to CARTO |
16 +
17 +Meta tags rather than an attribute per map because maps are created from half a
18 +dozen places — including the `<leaflet-map>` web component, inside a shadow
19 +root — and every one of them draws from the same server.
20 +
21 +## Configuration
22 +
23 +Read at runtime, so a release is repointed by restarting it rather than
24 +rebuilding it.
25 +
26 +| Variable | Meaning |
27 +| --- | --- |
28 +| `TILE_URL` | base tiles. The only one required to switch servers |
29 +| `TILE_URL_DARK` | drawn instead under a dark theme (defaults to `TILE_URL`) |
30 +| `TILE_LABELS_URL` | label-only overlay, drawn *above* the theme wash |
31 +| `TILE_LABELS_URL_DARK` | dark counterpart (defaults to `TILE_LABELS_URL`) |
32 +| `TILE_ATTRIBUTION` | attribution HTML for the corner of the map |
33 +| `TILE_SUBDOMAINS` | e.g. `abcd`, only if the URL contains `{s}` |
34 +| `TILE_MAX_ZOOM` | deepest zoom the server has tiles for |
35 +
36 +Set none of them and nothing is emitted into `<head>` at all, which the
37 +frontend reads as "use your default" rather than as "draw nothing".
38 +
39 +Light and dark are chosen per theme by `isDarkTheme()`, which reads daisyUI's
40 +`--b1` off the map container — so the maps follow the theme picker, not the OS.
41 +A caller can pin one with `variant: 'light' | 'dark'`; the web component takes
42 +that from its `basemap` attribute.
43 +
44 +`TILE_MAX_ZOOM` wins over the `maxZoom` a caller passes `addBasemap`. Callers
45 +pass the zoom the view wants, which a server without tiles that deep can only
46 +answer with 404s.
47 +
48 +Retina is opt-in: put `{r}` in `TILE_URL` only for a server that really serves
49 +`@2x` tiles. Leaflet's `detectRetina` has two modes and only one is safe to
50 +assume — with `{r}` it asks for the @2x tile, without it it requests the *next
51 +zoom down at half size*, which is four times the tiles and nothing at all at
52 +the server's deepest zoom.
53 +
54 +## tiles.neiam.org
55 +
56 +Our own renderd server, and what the deployment points at — see the `TILE_*`
57 +block in `app.yml`. Its landing page is a live layer switcher that prints the
58 +template of whichever layer is showing, so it is its own reference.
59 +
60 +```
61 +TILE_URL=https://tiles.neiam.org/grayscale/{z}/{x}/{y}.png
62 +TILE_URL_DARK=https://tiles.neiam.org/dark/{z}/{x}/{y}.png
63 +TILE_LABELS_URL=https://tiles.neiam.org/grayscale_only_labels/{z}/{x}/{y}.png
64 +TILE_LABELS_URL_DARK=https://tiles.neiam.org/dark_only_labels/{z}/{x}/{y}.png
65 +TILE_MAX_ZOOM=20
66 +```
67 +
68 +| Layer | Style |
69 +| --- | --- |
70 +| `/grayscale/{z}/{x}/{y}.png` | Positron-like |
71 +| `/dark/{z}/{x}/{y}.png` | Dark Matter-like |
72 +| `/tile/{z}/{x}/{y}.png` | standard OSM colour |
73 +| `/night/{z}/{x}/{y}.png` | warm dark |
74 +| `/blueprint/{z}/{x}/{y}.png` | blueprint |
75 +| `/grayscale_only_labels/…`, `/dark_only_labels/…` | transparent label overlays |
76 +
77 +256px tiles, no `@2x`, no `{s}`, north-america-only import. The base styles are
78 +opaque palette PNGs; the two `_only_labels` layers carry a `tRNS` chunk and
79 +render place names over nothing, which is what makes them usable as an overlay.
80 +Only the five base styles are listed on the landing page — the label layers are
81 +served but undocumented there, so this file is their reference.
82 +
83 +The built-in default in `basemap.js` is still CARTO; the deployment overrides
84 +it. Two reasons to leave the fallback where it is:
85 +
86 +- **North america only.** Anywhere else renders as empty tiles, not an error.
87 + The landing page's four cities are all inside the import.
88 +- **Cold tiles do not render reliably.** Measured 2026-08-28: an uncached tile
89 + hangs ~10s and then returns 404, 502 or 503 depending on how the request
90 + fails, and the same tile can keep failing across retries for minutes before
91 + it starts serving — NYC at z10–z12 did this on every layer while z4 and
92 + z19–z20 at the same point served immediately. Leaflet does not re-request a
93 + 404, so a failed tile is a permanent hole in that page's map rather than a
94 + slow one. This is the thing to fix on the server side; it is more visible to
95 + a user than anything else on this page.
96 +
97 +## The label-only layer
98 +
99 +CARTO publishes each basemap three ways: `light_all`, `light_nolabels` and
100 +`light_only_labels`. The default draws `_nolabels` under the theme tint and
101 +`_only_labels` above it, in a Leaflet pane at z-index 350 — above the wash
102 +(250), below the overlay (400) and marker (600) panes.
103 +
104 +That split is not decoration. The tint is a `mix-blend-mode: color` wash over
105 +the whole basemap, and type tinted along with everything behind it loses most
106 +of its contrast against the roads. Drawing the labels above the wash is what
107 +keeps place names readable on a tinted map. The cost is a second tile request
108 +per tile, which `labels: false` opts out of.
109 +
110 +tiles.neiam.org now serves `grayscale_only_labels` and `dark_only_labels`, so
111 +the deployment gets the same split. The frontend turns the label pane on as
112 +soon as **both** a light and a dark label URL are configured — both are
113 +required, because the theme can flip at any moment and a pane that existed
114 +under one theme and not the other would have labels appearing and disappearing
115 +with it.
116 +
117 +### Still open: is the base style label-free?
118 +
119 +CARTO's `_nolabels` exists so the base and the overlay do not both draw type.
120 +Our server has no `_nolabels` variant that we could find, which leaves the
121 +question of whether `/grayscale` and `/dark` bake their labels in. If they do,
122 +every place name is drawn twice — once under the wash, once above it. Because
123 +both come from the same style at the same placement they land on top of each
124 +other, so it reads as heavier type rather than as doubled type, but it is not
125 +what the design intends and it costs a second render of every label.
126 +
127 +Worth checking against a rendered city tile once the server is serving cold
128 +tiles reliably (see above — this could not be confirmed while writing this, as
129 +`/grayscale` was returning 503 for every zoom that was not already cached).
130 +Two ways out if it turns out labels are baked in:
131 +
132 +- publish `grayscale_nolabels` / `dark_nolabels` and point `TILE_URL` at those,
133 + which is the CARTO shape and needs no code change; or
134 +- drop `TILE_LABELS_URL*` and accept the tint over the labels, which is what
135 + the single-layer path already does.
136 +
137 +## Remaining gaps
138 +
139 +- **Extend the import past north america**, or accept that the instance is
140 + regional.
141 +- **`@2x` tiles**, then add `{r}` to `TILE_URL` — one line of config, and the
142 + retina path in `basemap.js` already handles it.
143 +
144 +Once cold-tile rendering is dependable, flipping the built-in default in
145 +`basemap.js` from CARTO to our server is a small change: `CARTO` in that file
146 +is just the fallback `tileSource()` returns when no `basemap-url` meta tag is
147 +present.
modified README.md
+3 −0
@@ -38,6 +38,9 @@ Debian/Ubuntu users may need to install `erlang-xmerl`
38 38 Postgres is expected on **port 54321** (not 5432) as `postgres`/`postgres`, database
39 39 `room_sanctum_dev` — see `config/dev.exs`. `docker-compose.yml` brings one up.
40 40
41 +Maps draw from CARTO unless a tile server is configured — see
42 +[CUSTOM_MAPS.md](CUSTOM_MAPS.md).
43 +
41 44 #### First-time setup
42 45
43 46 ```sh
modified app.yml
+24 −0
@@ -109,6 +109,30 @@ spec:
109 109 - name: PHX_HOST
110 110 value: ac.gmp.io
111 111
112 + # Basemap tiles, from our own renderd server rather than CARTO.
113 + # See CUSTOM_MAPS.md. The label-only overlays are what let the
114 + # theme tint wash the basemap without washing the place names.
115 + - name: TILE_URL
116 + value: https://tiles.neiam.org/grayscale/{z}/{x}/{y}.png
117 +
118 + - name: TILE_URL_DARK
119 + value: https://tiles.neiam.org/dark/{z}/{x}/{y}.png
120 +
121 + - name: TILE_LABELS_URL
122 + value: https://tiles.neiam.org/grayscale_only_labels/{z}/{x}/{y}.png
123 +
124 + - name: TILE_LABELS_URL_DARK
125 + value: https://tiles.neiam.org/dark_only_labels/{z}/{x}/{y}.png
126 +
127 + - name: TILE_MAX_ZOOM
128 + value: '20'
129 +
130 + - name: TILE_ATTRIBUTION
131 + value: >-
132 + &copy; <a
133 + href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>
134 + contributors
135 +
112 136 name: apollos-crib
113 137 ports:
114 138 - containerPort: 4000
modified apps/room_sanctum/assets/js/leaflet/basemap.js
+90 −25
@@ -1,28 +1,85 @@
1 1 import L from 'leaflet'
2 2
3 −// CARTO's greyscale basemaps. Keyless, and monochrome by design rather than
4 −// desaturated after the fact -- a CSS grayscale() filter over standard OSM
5 −// tiles flattens the colour-coded road hierarchy into near-identical greys,
6 −// whereas these are drawn for it, so labels and roads keep their separation
7 −// once a tint is laid over them.
3 +// CARTO's greyscale basemaps, the default when no server is configured.
4 +// Keyless, and monochrome by design rather than desaturated after the fact --
5 +// a CSS grayscale() filter over standard OSM tiles flattens the colour-coded
6 +// road hierarchy into near-identical greys, whereas these are drawn for it, so
7 +// labels and roads keep their separation once a tint is laid over them.
8 8 // CARTO publishes each basemap split into a label-free layer and a label-only
9 9 // layer as well as the combined `_all`. Drawing the labels as a separate layer
10 10 // above the wash is what keeps type readable: tinted along with everything
11 11 // else, place names lose most of their contrast against the roads behind them.
12 12 // The cost is a second tile request per tile, which `labels: false` opts out
13 13 // of by falling back to the combined layer.
14 −const TILE_URL = 'https://{s}.basemaps.cartocdn.com/'
14 +const CARTO_URL = 'https://{s}.basemaps.cartocdn.com/'
15 +const cartoUrl = (style) => CARTO_URL + style + '/{z}/{x}/{y}{r}.png'
15 16
16 −const STYLES = {
17 − light: { all: 'light_all', base: 'light_nolabels', labels: 'light_only_labels' },
18 − dark: { all: 'dark_all', base: 'dark_nolabels', labels: 'dark_only_labels' }
17 +const CARTO = {
18 + light: {
19 + all: cartoUrl('light_all'),
20 + base: cartoUrl('light_nolabels'),
21 + labels: cartoUrl('light_only_labels')
22 + },
23 + dark: {
24 + all: cartoUrl('dark_all'),
25 + base: cartoUrl('dark_nolabels'),
26 + labels: cartoUrl('dark_only_labels')
27 + },
28 + subdomains: 'abcd',
29 + maxZoom: null,
30 + retina: true,
31 + attribution:
32 + '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors ' +
33 + '&copy; <a href="https://carto.com/attributions">CARTO</a>'
19 34 }
20 35
21 −const tileUrl = (style) => TILE_URL + style + '/{z}/{x}/{y}{r}.png'
36 +// The server the page was served with, written into <head> as
37 +// <meta name="basemap-*"> by RoomSanctum.Basemap. Meta tags rather than an
38 +// attribute on each map: the maps are created from half a dozen places,
39 +// including a web component inside a shadow root, and all of them draw from
40 +// the same server.
41 +function meta(name) {
42 + const el = document.querySelector('meta[name="basemap-' + name + '"]')
43 + const value = el && el.content.trim()
44 + return value || null
45 +}
46 +
47 +// Resolved once per page. Only `basemap-url` is required to switch servers:
48 +// a server with a single style is drawn under both themes, and one with no
49 +// separate label layer simply takes the wash over its labels.
50 +let source = null
51 +
52 +function tileSource() {
53 + if (source) return source
54 +
55 + const url = meta('url')
56 + if (!url) {
57 + source = CARTO
58 + return source
59 + }
60 +
61 + const dark = meta('dark-url') || url
62 + const labels = meta('labels-url')
63 + const darkLabels = meta('dark-labels-url') || labels
64 + const maxZoom = parseInt(meta('max-zoom'), 10)
22 65
23 −const ATTRIBUTION =
24 − '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors ' +
25 − '&copy; <a href="https://carto.com/attributions">CARTO</a>'
66 + source = {
67 + light: { all: url, base: url, labels: labels },
68 + dark: { all: dark, base: dark, labels: darkLabels },
69 + subdomains: meta('subdomains') || '',
70 + maxZoom: Number.isFinite(maxZoom) ? maxZoom : null,
71 + // Leaflet's detectRetina has two modes, and only one of them is safe
72 + // to assume. With `{r}` in the template it asks the server for the @2x
73 + // tile; without it, it requests the *next zoom down* at half size --
74 + // four times the tiles, and nothing at all at the server's deepest
75 + // zoom. A plain renderd-style server has neither @2x nor the headroom,
76 + // so retina rendering is opt-in via `{r}` in TILE_URL.
77 + retina: url.includes('{r}'),
78 + attribution: meta('attribution') ||
79 + '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
80 + }
81 + return source
82 +}
26 83
27 84 // Which daisyUI variable the tint is taken from, and how far the wash is
28 85 // pushed.
@@ -94,7 +151,7 @@ function watchTheme() {
94 151 }
95 152
96 153 /**
97 − * Add a monochrome basemap to `map` and wash it in the current theme colour.
154 + * Add a basemap to `map` and wash it in the current theme colour.
98 155 *
99 156 * Returns a handle with `refresh()` (re-read the theme now) and `remove()`.
100 157 *
@@ -105,11 +162,13 @@ function watchTheme() {
105 162 * blend mix-blend-mode for the wash (default 'color')
106 163 * labels false to use the combined basemap and let the wash cover the
107 164 * labels too -- one tile request per tile instead of two, at the
108 − * cost of much less readable type (default true)
165 + * cost of much less readable type (default true; ignored when the
166 + * configured server publishes no label-only layer)
109 167 * styleFrom element to resolve custom properties against; defaults to the
110 168 * map container, which matters inside a shadow root where the
111 169 * host may sit under a different data-theme than <html>
112 − * maxZoom passed to the tile layer (default 20)
170 + * maxZoom passed to the tile layer (default 20), unless the configured
171 + * server declares a lower limit of its own
113 172 */
114 173 export function addBasemap(map, opts = {}) {
115 174 const container = map.getContainer()
@@ -118,17 +177,23 @@ export function addBasemap(map, opts = {}) {
118 177 const tintOpt = opts.tint || DEFAULT_TINT_VAR
119 178 const strength = opts.strength == null ? DEFAULT_STRENGTH : Number(opts.strength)
120 179
121 − const withLabels = opts.labels !== false
122 − const styleFor = () => STYLES[variant] || (isDarkTheme(styleFrom) ? STYLES.dark : STYLES.light)
123 − const baseUrl = () => tileUrl(withLabels ? styleFor().base : styleFor().all)
180 + const src = tileSource()
181 + // A server that publishes no label-only layer has nothing to draw above
182 + // the wash, so `labels` can only ever turn the second request off.
183 + const withLabels = opts.labels !== false && !!(src.light.labels && src.dark.labels)
184 + const styleFor = () => src[variant] || (isDarkTheme(styleFrom) ? src.dark : src.light)
185 + const baseUrl = () => (withLabels ? styleFor().base : styleFor().all)
124 186
125 187 const tileOpts = {
126 − subdomains: 'abcd',
127 − maxZoom: opts.maxZoom || 20,
128 − detectRetina: true
188 + subdomains: src.subdomains,
189 + // A configured server's own limit wins over the caller's: callers pass
190 + // the zoom the view wants, which a server without tiles that deep can
191 + // only answer with 404s.
192 + maxZoom: src.maxZoom || opts.maxZoom || 20,
193 + detectRetina: src.retina
129 194 }
130 195
131 − const tiles = L.tileLayer(baseUrl(), Object.assign({ attribution: ATTRIBUTION }, tileOpts))
196 + const tiles = L.tileLayer(baseUrl(), Object.assign({ attribution: src.attribution }, tileOpts))
132 197 .addTo(map)
133 198
134 199 // The wash has to be a Leaflet pane, not a child of the container.
@@ -165,7 +230,7 @@ export function addBasemap(map, opts = {}) {
165 230 const labelPane = map.createPane(LABEL_PANE)
166 231 labelPane.style.zIndex = LABEL_Z
167 232 labelPane.style.pointerEvents = 'none'
168 − labelTiles = L.tileLayer(tileUrl(styleFor().labels),
233 + labelTiles = L.tileLayer(styleFor().labels,
169 234 Object.assign({ pane: LABEL_PANE }, tileOpts)).addTo(map)
170 235 }
171 236
@@ -173,7 +238,7 @@ export function addBasemap(map, opts = {}) {
173 238 // setUrl no-ops when the template is unchanged, so this only refetches
174 239 // tiles when the theme actually crossed the light/dark line.
175 240 tiles.setUrl(baseUrl())
176 − if (labelTiles) labelTiles.setUrl(tileUrl(styleFor().labels))
241 + if (labelTiles) labelTiles.setUrl(styleFor().labels)
177 242
178 243 const color = tintOpt === 'none' ? null : asColor(
179 244 tintOpt.startsWith('--') ? cssVar(tintOpt, styleFrom) : tintOpt
added apps/room_sanctum/lib/room_sanctum/basemap.ex
+80 −0
@@ -0,0 +1,80 @@
1 +defmodule RoomSanctum.Basemap do
2 + @moduledoc """
3 + Which tile server the Leaflet basemaps draw from.
4 +
5 + Nothing is configured by default: the frontend falls back to CARTO's keyless
6 + greyscale basemaps, which is what every map used before this existed. An
7 + installation that would rather not send its users' viewports to a third party
8 + -- or that runs its own server -- points these at it instead.
9 +
10 + Set in `config/runtime.exs` from the environment, so a release is repointed
11 + without a rebuild:
12 +
13 + TILE_URL template for the base tiles, e.g.
14 + "https://tiles.example/styles/light/{z}/{x}/{y}.png"
15 + TILE_URL_DARK same, drawn instead when the theme is dark
16 + (defaults to TILE_URL -- one style for both themes)
17 + TILE_LABELS_URL optional label-only overlay, drawn above the theme
18 + wash so place names keep their contrast
19 + TILE_LABELS_URL_DARK dark counterpart of TILE_LABELS_URL
20 + TILE_ATTRIBUTION attribution HTML for the corner of the map
21 + TILE_SUBDOMAINS e.g. "abcd", only if the URL contains `{s}`
22 + TILE_MAX_ZOOM deepest zoom the server has tiles for
23 +
24 + Only `TILE_URL` is required to switch servers; the rest refine it. The values
25 + reach the browser as `<meta name="basemap-*">` tags, read by
26 + `assets/js/leaflet/basemap.js`.
27 +
28 + Our own server publishes exactly the pair this expects -- a Positron-like
29 + grayscale and a Dark Matter-like dark, the same two CARTO styles the default
30 + draws from:
31 +
32 + TILE_URL=https://tiles.neiam.org/grayscale/{z}/{x}/{y}.png
33 + TILE_URL_DARK=https://tiles.neiam.org/dark/{z}/{x}/{y}.png
34 + TILE_MAX_ZOOM=20
35 +
36 + It is not the default, because it is not a drop-in replacement for CARTO in
37 + two ways an installation has to decide about rather than inherit: its import
38 + is north america only, so anywhere else renders as empty tiles rather than an
39 + error, and it publishes no label-only layer, so the theme wash goes over the
40 + place names instead of under them. Neither is visible until someone pans off
41 + the continent or reads a tinted label.
42 +
43 + It serves no `@2x` tiles either, which needs nothing here -- retina is opt-in
44 + via `{r}` in `TILE_URL`, see the note in `assets/js/leaflet/basemap.js`.
45 + """
46 +
47 + @keys [
48 + url: "url",
49 + dark_url: "dark-url",
50 + labels_url: "labels-url",
51 + dark_labels_url: "dark-labels-url",
52 + attribution: "attribution",
53 + subdomains: "subdomains",
54 + max_zoom: "max-zoom"
55 + ]
56 +
57 + @doc """
58 + The configured basemap as `{meta_name, value}` pairs, blank entries dropped.
59 +
60 + Empty when nothing is configured, which is the signal to the frontend to use
61 + its built-in default rather than an explicit instruction to draw nothing.
62 + """
63 + def meta_tags do
64 + config = Application.get_env(:room_sanctum, :basemap, [])
65 +
66 + for {key, name} <- @keys,
67 + value = present(config[key]),
68 + do: {name, value}
69 + end
70 +
71 + defp present(nil), do: nil
72 + defp present(value) when is_integer(value), do: Integer.to_string(value)
73 +
74 + defp present(value) when is_binary(value) do
75 + case String.trim(value) do
76 + "" -> nil
77 + trimmed -> trimmed
78 + end
79 + end
80 +end
modified apps/room_sanctum/lib/room_sanctum_web/components/core_components.ex
+14 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/components/layouts/root.html.heex
+1 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/templates/layout/root.html.heex
+1 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum/basemap_test.exs
+54 −0

Click to load diff…

modified config/config.exs
+6 −0

Click to load diff…

modified config/runtime.exs
+12 −0

Click to load diff…

Parents: 8aca56e