neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Give the maps a themed basemap, moving markers and route lines
00ec7ed · Gabriel Morell · 2026-08-10 22:11
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: '© <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: '© <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: '© <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 | + '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors ' + | |
| 25 | + '© <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: '© <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