Give the maps a themed basemap, moving markers and route lines

00ec7ed · Gabriel Morell · 2026-08-10 22:11

16 files +639 -51
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/assets/js/app.js
+5 −12
@@ -34,6 +34,7 @@ import { Base64 } from 'js-base64'
34 34 // Import Web Components for Leaflet maps
35 35 import './leaflet/leaflet-map'
36 36 import './leaflet/leaflet-marker'
37 +import './leaflet/leaflet-line'
37 38 import './leaflet/leaflet-icon'
38 39
39 40 // Make compression libraries available globally for leaflet hook
@@ -46,6 +47,7 @@ window.Base64 = Base64;
46 47
47 48 import L from 'leaflet'
48 49 import 'leaflet-centermarker'
50 +import { addBasemap } from './leaflet/basemap'
49 51 import LeafletMap from './hooks/leaflet_map.js'
50 52
51 53 import LeafletMapBridge from './hooks/leaflet_map_bridge.js'
@@ -155,10 +157,7 @@ Hooks.mkMap = {
155 157 });
156 158 centerMarker.addTo(map);
157 159
158 − L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
159 − maxZoom: 19,
160 − attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
161 − }).addTo(map);
160 + addBasemap(map, { maxZoom: 19 });
162 161
163 162 // Search functionality
164 163 const searchInput = document.getElementById('location-search');
@@ -326,10 +325,7 @@ Hooks.mkTesterMap = {
326 325 // });
327 326 // marker.addTo(map);
328 327
329 − L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
330 − maxZoom: 19,
331 − attribution: '&copy; <a href="https//:openstreetmap.org/copyright">OpenStreetMap contributors</a>'
332 − }).addTo(map);
328 + addBasemap(map, { maxZoom: 19 });
333 329
334 330 this.handleEvent("add_marker", ({lat, lon}) => {
335 331 const marker = L.marker(L.latLng(lat, lon))
@@ -368,10 +364,7 @@ Hooks.mkShowMap = {
368 364 L.marker(coords).addTo(map);
369 365 }
370 366
371 − L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
372 − maxZoom: 19,
373 − attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
374 − }).addTo(map);
367 + addBasemap(map, { maxZoom: 19 });
375 368 }
376 369 }
377 370
modified apps/room_sanctum/assets/js/hooks/leaflet_map.js
+17 −5
@@ -4,6 +4,10 @@
4 4 * Optimized for handling thousands of markers with Canvas renderer and data compression
5 5 */
6 6
7 +// L itself comes from the global the Leaflet UMD bundle installs; this one is
8 +// ours, so it has to be imported.
9 +import { addBasemap } from '../leaflet/basemap'
10 +
7 11 const LeafletMap = {
8 12 mounted() {
9 13 this.isInitialized = false;
@@ -49,15 +53,26 @@ const LeafletMap = {
49 53 },
50 54
51 55 destroyed() {
56 + this.releaseBasemap();
52 57 if (this.map) {
53 58 this.map.remove();
54 59 }
55 60 },
56 61
62 + // The tint holds a module-level theme-change callback, so it has to be
63 + // released explicitly on every path that drops the map.
64 + releaseBasemap() {
65 + if (this.basemap) {
66 + this.basemap.remove();
67 + this.basemap = null;
68 + }
69 + },
70 +
57 71 initializeMap() {
58 72 // Only clear if we don't already have a map instance
59 73 if (this.map) {
60 74 console.log('Map already exists, removing...');
75 + this.releaseBasemap();
61 76 this.map.remove();
62 77 this.map = null;
63 78 }
@@ -88,11 +103,8 @@ const LeafletMap = {
88 103 scrollWheelZoom: true
89 104 });
90 105
91 − // Add OpenStreetMap tiles
92 − L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
93 − attribution: '© OpenStreetMap contributors',
94 − maxZoom: 18
95 − }).addTo(this.map);
106 + // Monochrome tiles, washed in the active theme colour.
107 + this.basemap = addBasemap(this.map, { maxZoom: 18 });
96 108
97 109 // Initialize separate layer groups for queries, vehicles, free bikes, and stations
98 110 this.queriesLayer = L.layerGroup().addTo(this.map);
added apps/room_sanctum/assets/js/leaflet/basemap.js
+211 −0
@@ -0,0 +1,211 @@
1 +import L from 'leaflet'
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.
8 +// CARTO publishes each basemap split into a label-free layer and a label-only
9 +// layer as well as the combined `_all`. Drawing the labels as a separate layer
10 +// above the wash is what keeps type readable: tinted along with everything
11 +// else, place names lose most of their contrast against the roads behind them.
12 +// The cost is a second tile request per tile, which `labels: false` opts out
13 +// of by falling back to the combined layer.
14 +const TILE_URL = 'https://{s}.basemaps.cartocdn.com/'
15 +
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' }
19 +}
20 +
21 +const tileUrl = (style) => TILE_URL + style + '/{z}/{x}/{y}{r}.png'
22 +
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>'
26 +
27 +// Which daisyUI variable the tint is taken from, and how far the wash is
28 +// pushed.
29 +//
30 +// 'color' takes hue and saturation from the wash and keeps the tiles'
31 +// luminance, so the basemap is recoloured rather than veiled: contrast is
32 +// identical to the untinted tiles, and an achromatic theme (lofi, black)
33 +// correctly leaves the map monochrome. Blends that redistribute luminance --
34 +// screen, lighten -- wash a dark basemap out to pale grey instead.
35 +//
36 +// 0.5 keeps the theme legible on the basemap while leaving the amber "already
37 +// queried" marker clearly separate from a warm basemap under the orange theme,
38 +// which the two start to merge into as the wash approaches 1.0.
39 +const DEFAULT_TINT_VAR = '--p'
40 +const DEFAULT_STRENGTH = 0.5
41 +const DEFAULT_BLEND = 'color'
42 +
43 +const PANE = 'themeTint'
44 +// Above the wash so labels keep CARTO's own contrast, and below the overlay
45 +// (400) and marker (600) panes so query and vehicle markers still draw on top.
46 +const LABEL_PANE = 'themeLabels'
47 +const LABEL_Z = '350'
48 +// Viewports of slack on each side of the wash. Leaflet animates a zoom by
49 +// scaling the whole map pane, so zooming out momentarily renders this pane at
50 +// half size; at 1 viewport of padding it is 3x the viewport and still covers
51 +// after that 0.5x. The extra area is close to free -- a solid-colour layer is
52 +// only rasterised and blended where it intersects the screen.
53 +const PANE_PADDING = 1
54 +
55 +// Every live tint on the page, so a theme switch can repaint all of them
56 +// without each map having to watch the document itself.
57 +const registry = new Set()
58 +let watching = false
59 +
60 +function cssVar(name, el) {
61 + return getComputedStyle(el || document.documentElement).getPropertyValue(name).trim()
62 +}
63 +
64 +// daisyUI stores its palette as bare HSL components ("242 29% 59%") so callers
65 +// can apply their own alpha. Anything else -- a literal colour handed in via
66 +// the `tint` attribute -- is passed through untouched.
67 +function asColor(value) {
68 + if (!value) return null
69 + return /^[\d.]+\s+[\d.]+%\s+[\d.]+%$/.test(value) ? `hsl(${value})` : value
70 +}
71 +
72 +// Themes here range from near-black to a deep navy, and the basemap has to
73 +// match the surrounding chrome or the map reads as a hole punched in the page.
74 +// base-100's lightness is the same signal daisyUI itself uses.
75 +export function isDarkTheme(el) {
76 + const match = cssVar('--b1', el).match(/^[\d.]+\s+[\d.]+%\s+([\d.]+)%$/)
77 + return match ? parseFloat(match[1]) < 50 : true
78 +}
79 +
80 +function watchTheme() {
81 + if (watching) return
82 + watching = true
83 +
84 + const repaint = () => registry.forEach((refresh) => refresh())
85 +
86 + new MutationObserver(repaint).observe(document.documentElement, {
87 + attributes: true,
88 + attributeFilter: ['data-theme']
89 + })
90 +
91 + // The picker writes the attribute and fires this; the observer above
92 + // catches the write, but system-theme changes only surface as the event.
93 + window.addEventListener('phx:set-theme', () => setTimeout(repaint, 0))
94 +}
95 +
96 +/**
97 + * Add a monochrome basemap to `map` and wash it in the current theme colour.
98 + *
99 + * Returns a handle with `refresh()` (re-read the theme now) and `remove()`.
100 + *
101 + * Options:
102 + * variant 'auto' (default) | 'light' | 'dark'
103 + * tint CSS colour, a daisyUI var name like '--s', or 'none'
104 + * strength 0..1, how much of the tint is applied (default 0.5)
105 + * blend mix-blend-mode for the wash (default 'color')
106 + * labels false to use the combined basemap and let the wash cover the
107 + * labels too -- one tile request per tile instead of two, at the
108 + * cost of much less readable type (default true)
109 + * styleFrom element to resolve custom properties against; defaults to the
110 + * map container, which matters inside a shadow root where the
111 + * host may sit under a different data-theme than <html>
112 + * maxZoom passed to the tile layer (default 20)
113 + */
114 +export function addBasemap(map, opts = {}) {
115 + const container = map.getContainer()
116 + const styleFrom = opts.styleFrom || container
117 + const variant = opts.variant && opts.variant !== 'auto' ? opts.variant : null
118 + const tintOpt = opts.tint || DEFAULT_TINT_VAR
119 + const strength = opts.strength == null ? DEFAULT_STRENGTH : Number(opts.strength)
120 +
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)
124 +
125 + const tileOpts = {
126 + subdomains: 'abcd',
127 + maxZoom: opts.maxZoom || 20,
128 + detectRetina: true
129 + }
130 +
131 + const tiles = L.tileLayer(baseUrl(), Object.assign({ attribution: ATTRIBUTION }, tileOpts))
132 + .addTo(map)
133 +
134 + // The wash has to be a Leaflet pane, not a child of the container.
135 + // `.leaflet-map-pane` is itself a pane at z-index 400 and so forms a
136 + // stacking context -- the tile pane's 200 and the marker pane's 600 are
137 + // numbers *inside* it. A sibling of the map pane therefore cannot be
138 + // slipped between them at any z-index; it paints under the whole map.
139 + // As a pane the tint is a true sibling of the tile pane, and the map
140 + // pane's stacking context also confines the blend for free.
141 + const tint = map.createPane(PANE)
142 + tint.classList.add('leaflet-theme-tint')
143 + tint.style.zIndex = '250'
144 + tint.style.pointerEvents = 'none'
145 + tint.style.mixBlendMode = opts.blend || DEFAULT_BLEND
146 +
147 + // Panes translate with the map, so the wash is sized to the viewport and
148 + // re-anchored as the map moves. The padding is insurance for zoom
149 + // animations, where the pane is being scaled by CSS and the layer-point
150 + // maths below is momentarily stale.
151 + const reposition = () => {
152 + const size = map.getSize()
153 + const padX = size.x * PANE_PADDING
154 + const padY = size.y * PANE_PADDING
155 + tint.style.width = size.x + padX * 2 + 'px'
156 + tint.style.height = size.y + padY * 2 + 'px'
157 + L.DomUtil.setPosition(tint, map.containerPointToLayerPoint([-padX, -padY]))
158 + }
159 +
160 + map.on('move zoom viewreset resize zoomend moveend', reposition)
161 + reposition()
162 +
163 + let labelTiles = null
164 + if (withLabels) {
165 + const labelPane = map.createPane(LABEL_PANE)
166 + labelPane.style.zIndex = LABEL_Z
167 + labelPane.style.pointerEvents = 'none'
168 + labelTiles = L.tileLayer(tileUrl(styleFor().labels),
169 + Object.assign({ pane: LABEL_PANE }, tileOpts)).addTo(map)
170 + }
171 +
172 + const refresh = () => {
173 + // setUrl no-ops when the template is unchanged, so this only refetches
174 + // tiles when the theme actually crossed the light/dark line.
175 + tiles.setUrl(baseUrl())
176 + if (labelTiles) labelTiles.setUrl(tileUrl(styleFor().labels))
177 +
178 + const color = tintOpt === 'none' ? null : asColor(
179 + tintOpt.startsWith('--') ? cssVar(tintOpt, styleFrom) : tintOpt
180 + )
181 +
182 + tint.style.background = color || 'transparent'
183 + tint.style.opacity = color ? String(strength) : '0'
184 + }
185 +
186 + refresh()
187 + registry.add(refresh)
188 + watchTheme()
189 +
190 + return {
191 + tileLayer: tiles,
192 + labelLayer: labelTiles,
193 + element: tint,
194 + refresh,
195 + remove() {
196 + registry.delete(refresh)
197 + map.off('move zoom viewreset resize zoomend moveend', reposition)
198 + tint.remove()
199 + map.removeLayer(tiles)
200 + if (labelTiles) {
201 + map.removeLayer(labelTiles)
202 + const labelPane = map.getPane(LABEL_PANE)
203 + if (labelPane) labelPane.remove()
204 + }
205 + if (map._panes) {
206 + delete map._panes[PANE]
207 + delete map._panes[LABEL_PANE]
208 + }
209 + }
210 + }
211 +}
added apps/room_sanctum/assets/js/leaflet/leaflet-line.js
+60 −0
@@ -0,0 +1,60 @@
1 +// A polyline expressed as a DOM child of <leaflet-map>, so route geometry
2 +// arrives and leaves the same way markers do -- LiveView adds and removes the
3 +// elements, the map's MutationObserver notices.
4 +class LeafletLine extends HTMLElement {
5 + constructor() {
6 + super();
7 + this.attachShadow({ mode: 'open' });
8 +
9 + // Data only; the drawing happens on the Leaflet canvas.
10 + this.shadowRoot.innerHTML = `
11 + <style>
12 + :host { display: none; }
13 + </style>
14 + <slot></slot>
15 + `;
16 + }
17 +
18 + static get observedAttributes() {
19 + return ['points', 'color', 'weight', 'opacity'];
20 + }
21 +
22 + attributeChangedCallback(name, oldValue, newValue) {
23 + if (oldValue !== newValue) {
24 + this.dispatchEvent(new CustomEvent('line-updated', {
25 + bubbles: true,
26 + detail: { attribute: name, oldValue, newValue }
27 + }));
28 + }
29 + }
30 +
31 + // [[lat, lng], ...]; anything unparseable is treated as no line at all
32 + // rather than throwing inside the map's observer callback.
33 + getPoints() {
34 + const raw = this.getAttribute('points');
35 + if (!raw) return [];
36 +
37 + try {
38 + const parsed = JSON.parse(raw);
39 + if (!Array.isArray(parsed)) return [];
40 + return parsed.filter(
41 + (p) => Array.isArray(p) && p.length === 2 && isFinite(p[0]) && isFinite(p[1])
42 + );
43 + } catch (e) {
44 + console.warn('leaflet-line: could not parse points', e);
45 + return [];
46 + }
47 + }
48 +
49 + getStyle() {
50 + return {
51 + color: this.getAttribute('color') || '#94a3b8',
52 + weight: parseFloat(this.getAttribute('weight')) || 2,
53 + opacity: this.hasAttribute('opacity') ? parseFloat(this.getAttribute('opacity')) : 0.35
54 + };
55 + }
56 +}
57 +
58 +window.customElements.define('leaflet-line', LeafletLine);
59 +
60 +export default LeafletLine;
modified apps/room_sanctum/assets/js/leaflet/leaflet-map.js
+247 −25
@@ -4,6 +4,7 @@ import L from 'leaflet'
4 4 // document cannot reach -- without this Leaflet's tiles have no positioning
5 5 // rules and lay themselves out as a grid of loose images.
6 6 import leafletCss from 'leaflet/dist/leaflet.css'
7 +import { addBasemap } from './basemap'
7 8
8 9 const template = document.createElement('template');
9 10 template.innerHTML = `
@@ -69,9 +70,28 @@ class LeafletMap extends HTMLElement {
69 70
70 71 this.map = L.map(this.mapElement).setView([lat, lng], zoom);
71 72
72 − L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
73 − attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
74 − }).addTo(this.map);
73 + // Resolve theme variables against the host rather than <html>: custom
74 + // properties inherit through the shadow boundary, so this picks up any
75 + // data-theme set closer to the element.
76 + this.basemap = addBasemap(this.map, {
77 + styleFrom: this,
78 + variant: this.getAttribute('basemap') || 'auto',
79 + tint: this.getAttribute('tint') || undefined,
80 + strength: this.getAttribute('tint-strength') || undefined,
81 + // labels="false" trades readable type for one tile request per
82 + // tile instead of two.
83 + labels: this.getAttribute('labels') !== 'false'
84 + });
85 +
86 + // Route lines sit above the theme wash but below the basemap labels and
87 + // every marker pane, so they read as part of the map rather than as
88 + // something drawn over it.
89 + const linePane = this.map.createPane('routeLines');
90 + linePane.style.zIndex = '260';
91 + linePane.style.pointerEvents = 'none';
92 + this.lineRenderer = L.canvas({ pane: 'routeLines', padding: 0.5 });
93 + this.linesLayer = L.layerGroup().addTo(this.map);
94 + this.lines = new Map();
75 95
76 96 // Initialize layer groups for different marker types
77 97 this.queriesLayer = L.layerGroup().addTo(this.map);
@@ -103,26 +123,64 @@ class LeafletMap extends HTMLElement {
103 123 this.observer = new MutationObserver((mutations) => {
104 124 mutations.forEach((mutation) => {
105 125 mutation.addedNodes.forEach(node => {
106 − if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'LEAFLET-MARKER') {
107 − this.addMarkerElement(node);
108 − }
126 + if (node.nodeType !== Node.ELEMENT_NODE) return;
127 + if (node.tagName === 'LEAFLET-MARKER') this.addMarkerElement(node);
128 + if (node.tagName === 'LEAFLET-LINE') this.addLineElement(node);
109 129 });
110 −
130 +
111 131 mutation.removedNodes.forEach(node => {
112 − if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'LEAFLET-MARKER') {
113 − this.removeMarkerElement(node);
114 − }
132 + if (node.nodeType !== Node.ELEMENT_NODE) return;
133 + if (node.tagName === 'LEAFLET-MARKER') this.removeMarkerElement(node);
134 + if (node.tagName === 'LEAFLET-LINE') this.removeLineElement(node);
115 135 });
116 136 });
117 137 });
118 138
119 139 this.observer.observe(this, { childList: true, subtree: true });
120 140
121 − // Process existing markers
141 + // leaflet-marker has always announced attribute changes, but nothing
142 + // listened, and the MutationObserver above only watches childList. A
143 + // marker that moved therefore kept the position it was created at for
144 + // the life of the element -- which is every GTFS vehicle and every
145 + // free bike, since LiveView patches their lat/lng in place and their
146 + // ids are stable.
147 + this.pendingMarkerUpdates = new Map();
148 + this.addEventListener('marker-updated', (event) => {
149 + const markerEl = event.target;
150 + if (!markerEl || markerEl.tagName !== 'LEAFLET-MARKER') return;
151 +
152 + const changed = this.pendingMarkerUpdates.get(markerEl) || new Set();
153 + changed.add(event.detail && event.detail.attribute);
154 + this.pendingMarkerUpdates.set(markerEl, changed);
155 +
156 + // lat and lng arrive as two separate events; coalesce so a move is
157 + // one update rather than two. A microtask rather than a frame:
158 + // LiveView patches every attribute in a single task, so this still
159 + // batches the whole update, and unlike requestAnimationFrame it is
160 + // not suspended while the tab is in the background.
161 + if (this.markerUpdateQueued) return;
162 + this.markerUpdateQueued = true;
163 + queueMicrotask(() => {
164 + this.markerUpdateQueued = false;
165 + const batch = this.pendingMarkerUpdates;
166 + this.pendingMarkerUpdates = new Map();
167 + batch.forEach((attrs, el) => this.applyMarkerUpdate(el, attrs));
168 + });
169 + });
170 +
171 + this.addEventListener('line-updated', (event) => {
172 + const lineEl = event.target;
173 + if (lineEl && lineEl.tagName === 'LEAFLET-LINE') this.addLineElement(lineEl);
174 + });
175 +
176 + // Process existing markers and lines
122 177 setTimeout(() => {
123 178 this.querySelectorAll('leaflet-marker').forEach(markerEl => {
124 179 this.addMarkerElement(markerEl);
125 180 });
181 + this.querySelectorAll('leaflet-line').forEach(lineEl => {
182 + this.addLineElement(lineEl);
183 + });
126 184 }, 200);
127 185 }
128 186
@@ -133,11 +191,76 @@ class LeafletMap extends HTMLElement {
133 191 if (this.resizeObserver) {
134 192 this.resizeObserver.disconnect();
135 193 }
194 + // Before map.remove(), and unconditionally: the tint registers a
195 + // theme-change callback module-side, which would otherwise outlive
196 + // every map torn down by a LiveView navigation.
197 + if (this.basemap) {
198 + this.basemap.remove();
199 + this.basemap = null;
200 + }
136 201 if (this.map) {
137 202 this.map.remove();
138 203 }
139 204 }
140 205
206 + addLineElement(lineEl) {
207 + if (!this.map) {
208 + setTimeout(() => this.addLineElement(lineEl), 100);
209 + return;
210 + }
211 +
212 + const lineId = lineEl.getAttribute('id');
213 + const points = lineEl.getPoints ? lineEl.getPoints() : [];
214 +
215 + this.removeLineById(lineId);
216 + if (points.length < 2) return;
217 +
218 + const style = lineEl.getStyle();
219 + const polyline = L.polyline(points, {
220 + renderer: this.lineRenderer,
221 + pane: 'routeLines',
222 + interactive: false,
223 + ...style
224 + });
225 +
226 + this.linesLayer.addLayer(polyline);
227 + this.lines.set(lineId, polyline);
228 + }
229 +
230 + removeLineElement(lineEl) {
231 + this.removeLineById(lineEl.getAttribute('id'));
232 + }
233 +
234 + removeLineById(lineId) {
235 + const existing = this.lines.get(lineId);
236 + if (!existing) return;
237 + this.linesLayer.removeLayer(existing);
238 + this.lines.delete(lineId);
239 + }
240 +
241 + // A pure move slides the existing layer, which is far cheaper than
242 + // rebuilding it and leaves an open popup open -- worth having when a whole
243 + // fleet shifts every refresh. Anything that changes how the marker looks
244 + // has to go through addMarkerElement, which recreates it.
245 + applyMarkerUpdate(markerEl, changedAttributes) {
246 + if (!this.map || !markerEl.isConnected) return;
247 +
248 + const markerId = markerEl.getAttribute('id');
249 + const existing = markerId && this.markers.get(markerId);
250 + const positionOnly = [...changedAttributes].every((name) => name === 'lat' || name === 'lng');
251 +
252 + if (existing && existing.setLatLng && positionOnly) {
253 + const lat = parseFloat(markerEl.getAttribute('lat'));
254 + const lng = parseFloat(markerEl.getAttribute('lng'));
255 + if (!isNaN(lat) && !isNaN(lng)) {
256 + existing.setLatLng([lat, lng]);
257 + return;
258 + }
259 + }
260 +
261 + this.addMarkerElement(markerEl);
262 + }
263 +
141 264 addMarkerElement(markerEl) {
142 265 if (!this.map) {
143 266 // Map not ready yet, try again later
@@ -243,22 +366,114 @@ class LeafletMap extends HTMLElement {
243 366 }
244 367
245 368 createVehicleMarker(lat, lng, markerEl) {
246 − // Custom icon for vehicles
369 + // An explicit <leaflet-icon icon-url> still wins, but the default is
370 + // drawn rather than fetched. It used to fall back to
371 + // /images/vehicle-icon.png, which is not in priv/static -- so every
372 + // transit vehicle rendered a broken image, and since Leaflet's marker
373 + // alt defaults to "Marker" what you actually saw was clipped alt text
374 + // where the vehicle should be.
247 375 const iconEl = markerEl.querySelector('leaflet-icon');
248 − const iconUrl = iconEl?.getAttribute('icon-url') || '/images/vehicle-icon.png';
249 − const iconSize = [
250 − parseInt(iconEl?.getAttribute('width')) || 32,
251 − parseInt(iconEl?.getAttribute('height')) || 32
252 − ];
376 + const iconUrl = iconEl?.getAttribute('icon-url');
377 +
378 + if (iconUrl) {
379 + const iconSize = [
380 + parseInt(iconEl.getAttribute('width')) || 32,
381 + parseInt(iconEl.getAttribute('height')) || 32
382 + ];
383 +
384 + const icon = L.icon({
385 + iconUrl: iconUrl,
386 + iconSize: iconSize,
387 + iconAnchor: [iconSize[0] / 2, iconSize[1] / 2],
388 + popupAnchor: [0, -iconSize[1] / 2],
389 + // Empty rather than Leaflet's "Marker" default: if this URL is
390 + // also missing, show nothing instead of a word on the map.
391 + alt: ''
392 + });
253 393
254 − const icon = L.icon({
255 − iconUrl: iconUrl,
256 − iconSize: iconSize,
257 − iconAnchor: [iconSize[0]/2, iconSize[1]/2],
258 − popupAnchor: [0, -iconSize[1]/2]
259 − });
394 + return L.marker([lat, lng], { icon }).bindPopup(() => this.createPopupContent(markerEl));
395 + }
396 +
397 + const bearing = parseFloat(markerEl.getAttribute('bearing'));
398 +
399 + return L.marker([lat, lng], {
400 + icon: this.createVehicleIcon(
401 + this.getMarkerColor(markerEl),
402 + this.getStrokeColor(markerEl),
403 + isNaN(bearing) ? null : bearing,
404 + LeafletMap.VEHICLE_KIND_BY_ROUTE_TYPE[markerEl.getAttribute('route-type')],
405 + 26
406 + ),
407 + keyboard: false,
408 + }).bindPopup(() => this.createPopupContent(markerEl));
409 + }
410 +
411 + // Glyphs are drawn rather than pulled from Font Awesome: the map lives in
412 + // a shadow root, and the document's .fa-* rules do not cross that
413 + // boundary, so an <i class="fa-bus"> renders as nothing in here.
414 + //
415 + // GTFS route_type is finer-grained than a 10px glyph can express, so the
416 + // types are grouped down to shapes that stay legible at marker size.
417 + static get VEHICLE_GLYPHS() {
418 + return {
419 + // rounded body, windscreen band, two wheels
420 + bus: '<path d="M8 8.5h8v5.2H8z" fill="#fff"/><path d="M8.6 9.4h6.8v1.8H8.6z" fill="currentColor"/>' +
421 + '<circle cx="9.6" cy="14.4" r="0.9" fill="#fff"/><circle cx="14.4" cy="14.4" r="0.9" fill="#fff"/>',
422 + // body with a pantograph stroke on the roof
423 + tram: '<path d="M8.4 8.6h7.2v5.4H8.4z" fill="#fff"/><path d="M9.1 9.4h5.8v1.7H9.1z" fill="currentColor"/>' +
424 + '<path d="M12 6.6v2" stroke="#fff" stroke-width="1" stroke-linecap="round"/>',
425 + // blunt-nosed carriage
426 + train: '<path d="M8.4 8.4h7.2v4.4a2.4 2.4 0 0 1-2.4 2.4h-2.4a2.4 2.4 0 0 1-2.4-2.4z" fill="#fff"/>' +
427 + '<path d="M9.2 9.2h5.6v2H9.2z" fill="currentColor"/>',
428 + // hull plus a short mast
429 + ferry: '<path d="M7.6 12.6h8.8l-1.4 2.6H9z" fill="#fff"/><path d="M11.4 8.2h1.2v4h-1.2z" fill="#fff"/>',
430 + // cabin hanging from a cable
431 + gondola: '<path d="M6.6 8h10.8" stroke="#fff" stroke-width="1" stroke-linecap="round"/>' +
432 + '<path d="M12 8v1.8" stroke="#fff" stroke-width="1"/>' +
433 + '<path d="M9.6 9.8h4.8v4.4H9.6z" fill="#fff"/>'
434 + };
435 + }
436 +
437 + static get VEHICLE_KIND_BY_ROUTE_TYPE() {
438 + return {
439 + '0': 'tram', // tram, streetcar, light rail
440 + '1': 'train', // subway, metro
441 + '2': 'train', // rail
442 + '3': 'bus',
443 + '4': 'ferry',
444 + '5': 'tram', // cable tram
445 + '6': 'gondola', // aerial lift
446 + '7': 'gondola', // funicular
447 + '11': 'bus', // trolleybus
448 + '12': 'train' // monorail
449 + };
450 + }
451 +
452 + // A disc carrying the vehicle kind, with a pointer along the direction of
453 + // travel. The pointer rotates; the glyph deliberately does not, so it
454 + // stays readable whichever way the vehicle is heading.
455 + createVehicleIcon(fill, stroke, bearing, kind, size) {
456 + const pointer =
457 + bearing === null
458 + ? ''
459 + : `<polygon points="12,0.8 15.2,6.4 8.8,6.4" fill="${fill}" stroke="${stroke}"
460 + stroke-width="1.4" stroke-linejoin="round"
461 + transform="rotate(${bearing} 12 12)" />`;
462 +
463 + const glyph = LeafletMap.VEHICLE_GLYPHS[kind] || '';
260 464
261 − return L.marker([lat, lng], { icon }).bindPopup(() => this.createPopupContent(markerEl));
465 + return L.divIcon({
466 + className: 'leaflet-vehicle-icon',
467 + html: `<svg width="${size}" height="${size}" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
468 + style="color:${fill}">
469 + ${pointer}
470 + <circle cx="12" cy="12" r="7.4" fill="${fill}" stroke="${stroke}" stroke-width="1.4" />
471 + ${glyph}
472 + </svg>`,
473 + iconSize: [size, size],
474 + iconAnchor: [size / 2, size / 2],
475 + popupAnchor: [0, -size / 2],
476 + });
262 477 }
263 478
264 479 createFreeBikeMarker(lat, lng, markerEl) {
@@ -305,10 +520,17 @@ class LeafletMap extends HTMLElement {
305 520 parseInt(iconEl.getAttribute('height')) || 32
306 521 ];
307 522
523 + // No icon-url means there is no image to switch to; leave whatever the
524 + // marker already draws rather than replacing it with a broken image.
525 + if (!iconUrl) return;
526 +
308 527 const newIcon = L.icon({
309 528 iconUrl: iconUrl,
310 529 iconSize: iconSize,
311 − iconAnchor: iconSize
530 + iconAnchor: iconSize,
531 + // Leaflet defaults this to "Marker", which is what shows when the
532 + // image fails to load.
533 + alt: ''
312 534 });
313 535
314 536 leafletMarker.setIcon(newIcon);
modified apps/room_sanctum/assets/js/leaflet/leaflet-marker.js
+1 −1
@@ -15,7 +15,7 @@ class LeafletMarker extends HTMLElement {
15 15 }
16 16
17 17 static get observedAttributes() {
18 − return ['lat', 'lng', 'name', 'type', 'tint', 'vehicle-id', 'route-id', 'selected'];
18 + return ['lat', 'lng', 'name', 'type', 'tint', 'bearing', 'route-type', 'vehicle-id', 'route-id', 'selected'];
19 19 }
20 20
21 21 attributeChangedCallback(name, oldValue, newValue) {
modified apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+98 −8
@@ -12,6 +12,15 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
12 12
13 13 <.query_geospatial_map queries={@queries} selected_tint={@tint} />
14 14 """
15 + attr :id, :string,
16 + default: "geospatial-map",
17 + doc: """
18 + DOM id for the map and the prefix for its marker ids. Needs to differ when
19 + two maps can be on screen together -- the tester map and the offering's
20 + map view can both be open, and duplicate ids let LiveView's patching move
21 + markers between them.
22 + """
23 +
15 24 attr :queries, :list, required: true
16 25 attr :selected_tint, :string, default: nil
17 26 attr :class, :string, default: ""
@@ -35,17 +44,72 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
35 44 attr :station_statuses, :list, default: []
36 45 attr :source_tint, :string, default: nil
37 46
47 + attr :focus, :map,
48 + default: nil,
49 + doc: """
50 + %{lat:, lng:} to centre on, instead of the centroid of the markers. Needed
51 + to look at one place: the computed view fits everything on screen and
52 + bottoms out at zoom 13, which is too far back to read a single stop.
53 + """
54 +
55 + attr :focus_zoom, :integer,
56 + default: 15,
57 + doc: "Zoom used with :focus. Only consulted when :focus is set."
58 +
59 + attr :route_types, :map,
60 + default: %{},
61 + doc: """
62 + route_id => GTFS route_type, used to pick the glyph a vehicle is drawn
63 + with. Vehicle positions only carry a route_id, and the type lives on the
64 + route, so the caller resolves it once rather than per marker.
65 + """
66 +
67 + attr :route_lines, :list,
68 + default: [],
69 + doc: """
70 + Muted polylines under the markers: [%{id:, points: [[lat, lng], ...],
71 + color:}]. Empty by default -- on a full feed this is hundreds of lines.
72 + """
73 +
74 + attr :route_lines_event, :string,
75 + default: nil,
76 + doc: """
77 + When set, the map carries its own route-lines toggle pushing this event.
78 + Left nil the control is not rendered, which is what a map whose owner
79 + cannot supply route geometry should do.
80 + """
81 +
82 + attr :show_route_lines, :boolean,
83 + default: false,
84 + doc: "Whether the toggle reads as on. The state lives with the caller."
85 +
86 + attr :legend, :boolean,
87 + default: true,
88 + doc: """
89 + The legend counts marker types and explains itself when there is nothing to
90 + show. Turn it off where the map is deliberately narrow -- a single place --
91 + and its "no queries found" empty state would only be noise.
92 + """
93 +
38 94 def query_geospatial_map(assigns) do
39 95 assigns =
40 96 assigns
41 97 |> assign(:map_queries, get_mappable_queries(assigns.queries))
42 − |> assign(:map_vehicles, format_vehicle_positions(assigns.vehicle_positions))
98 + |> assign(:map_vehicles, format_vehicle_positions(assigns.vehicle_positions, assigns.route_types))
43 99 |> assign(:map_free_bikes, format_free_bikes(assigns.free_bikes))
44 100 |> assign(:map_stations, format_stations(assigns.stations, Map.get(assigns, :station_statuses, []), Map.get(assigns, :source_tint, nil)))
45 101 groups = [assigns.map_queries, assigns.map_vehicles, assigns.map_free_bikes, assigns.map_stations]
46 102 all_points = Enum.concat(groups)
47 − # Centre on everything, even any points a cap would drop.
48 − {centre_lat, centre_lng, zoom} = view_for(all_points)
103 + # Centre on everything, even any points a cap would drop -- unless the
104 + # caller named a place to look at.
105 + {centre_lat, centre_lng, zoom} =
106 + case assigns.focus do
107 + %{lat: lat, lng: lng} when is_number(lat) and is_number(lng) ->
108 + {lat, lng, assigns.focus_zoom}
109 +
110 + _ ->
111 + view_for(all_points)
112 + end
49 113
50 114 # Every marker type is a canvas circleMarker sharing one renderer, so the
51 115 # whole system draws without trouble; the cap is opt-in rather than default.
@@ -71,15 +135,38 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
71 135 <%!-- No phx-update here on purpose: the element patches lat/lng/zoom through
72 136 attributeChangedCallback and markers through a MutationObserver, so
73 137 LiveView should morph it in place rather than replace it. --%>
74 − <div class="mt-4" id="geospatial-map-wrap">
138 + <div class="mt-4 relative" id={"#{@id}-wrap"}>
139 + <%!-- Above Leaflet's own controls, which top out at z-index 1000
140 + inside the map's shadow root. --%>
141 + <div :if={@route_lines_event} class="absolute top-2 right-2 z-[1100]">
142 + <button
143 + type="button"
144 + class={"btn btn-xs gap-1 shadow #{if @show_route_lines, do: "btn-primary", else: "btn-neutral"}"}
145 + phx-click={@route_lines_event}
146 + title={if @show_route_lines, do: "Hide route lines", else: "Show route lines"}
147 + >
148 + <i class="fa-solid fa-route"></i>
149 + Routes
150 + </button>
151 + </div>
152 +
75 153 <leaflet-map
76 − id="geospatial-map"
154 + id={@id}
77 155 class="w-full block rounded-lg overflow-hidden"
78 156 style={"height: #{@height}"}
79 157 lat={@centre_lat}
80 158 lng={@centre_lng}
81 159 zoom={@zoom}
82 160 >
161 + <leaflet-line
162 + :for={line <- @route_lines}
163 + id={"#{@id}-line-#{line.id}"}
164 + points={Jason.encode!(line.points)}
165 + color={Map.get(line, :color) || "#94a3b8"}
166 + weight="2"
167 + opacity="0.3"
168 + ></leaflet-line>
169 +
83 170 <%= for p <- @all_points do %>
84 171 <%!-- Prefixed: LiveView rejects a numeric DOM id, and queries carry a
85 172 bare integer id while the other formatters emit strings. --%>
@@ -89,7 +176,9 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
89 176 name={Map.get(p, :name)}
90 177 type={Map.get(p, :type)}
91 178 tint={Map.get(p, :tint)}
92 − id={"map-marker-#{p.id}"}
179 + bearing={Map.get(p, :bearing)}
180 + route-type={Map.get(p, :route_type)}
181 + id={"#{@id}-marker-#{p.id}"}
93 182 data-has-query={
94 183 if Map.get(p, :type) == "station" &&
95 184 MapSet.member?(@queried, to_string(Map.get(p, :station_id))),
@@ -124,7 +213,7 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
124 213 </p>
125 214 <% end %>
126 215 </div>
127 − <div class="mt-4 bg-base-100 border border-base-300 rounded-lg p-4">
216 + <div :if={@legend} class="mt-4 bg-base-100 border border-base-300 rounded-lg p-4">
128 217 <div class="flex items-center justify-between mb-3">
129 218 <h3 class="text-sm font-semibold text-base-content">
130 219 Map Legend
@@ -478,7 +567,7 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
478 567 end
479 568
480 569 # Format vehicle positions for map display
481 − defp format_vehicle_positions(vehicle_positions) do
570 + defp format_vehicle_positions(vehicle_positions, route_types \\ %{}) do
482 571 vehicle_positions
483 572 |> Enum.filter(fn vehicle ->
484 573 vehicle.latitude != nil && vehicle.longitude != nil
@@ -493,6 +582,7 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
493 582 lat: vehicle.latitude,
494 583 lng: vehicle.longitude,
495 584 bearing: vehicle.bearing,
585 + route_type: Map.get(route_types, vehicle.route_id),
496 586 timestamp: vehicle.timestamp,
497 587 icon: "fa-bus" # or different icon based on vehicle type
498 588 }

Parents: 521323d