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.
Patch the map instead of rebuilding it every refresh
608b1f7 · Gabriel Morell · 2026-08-31 15:28
Message
{commit_body(@commit)}
Files changed
modified
apps/room_sanctum/assets/js/leaflet/leaflet-map.js
+225
−40
@@ -20,6 +20,23 @@ template.innerHTML = `
| 20 | 20 | height: 100%; |
| 21 | 21 | min-height: 400px; |
| 22 | 22 | } |
| 23 | + /* Only shown once the user has moved the map themselves; until then the | |
| 24 | + map follows the data and there is nothing to reset to. */ | |
| 25 | + .leaflet-reset-view button { | |
| 26 | + display: flex; | |
| 27 | + align-items: center; | |
| 28 | + gap: 4px; | |
| 29 | + padding: 3px 7px; | |
| 30 | + font: 600 11px/1.4 system-ui, sans-serif; | |
| 31 | + color: #334155; | |
| 32 | + background: #fff; | |
| 33 | + border: none; | |
| 34 | + border-radius: 4px; | |
| 35 | + cursor: pointer; | |
| 36 | + } | |
| 37 | + .leaflet-reset-view button:hover { | |
| 38 | + background: #f1f5f9; | |
| 39 | + } | |
| 23 | 40 | </style> |
| 24 | 41 | <div class="map-container"> |
| 25 | 42 | <slot></slot> |
@@ -93,7 +110,17 @@ class LeafletMap extends HTMLElement {
| 93 | 110 | // High-performance markers tracking |
| 94 | 111 | this.markers = new Map(); // Track all markers by ID |
| 95 | 112 | this.markerElements = new Map(); // Track marker DOM elements |
| 113 | + // Which layer group each marker went into. Removing a marker from the | |
| 114 | + // map alone leaves the group still holding it, so the group grows for | |
| 115 | + // the life of the page and a re-added group brings ghosts back. | |
| 116 | + this.markerLayers = new Map(); | |
| 117 | + this.lines = new Map(); | |
| 96 | 118 | this.canvasRenderer = null; |
| 119 | + | |
| 120 | + // The map follows the computed view until the user moves it, then | |
| 121 | + // stays put and offers to start following again. | |
| 122 | + this.followView = true; | |
| 123 | + this.programmaticView = false; | |
| 97 | 124 | |
| 98 | 125 | // Initialize after DOM is ready |
| 99 | 126 | setTimeout(() => this.initializeMap(), 100); |
@@ -105,15 +132,101 @@ class LeafletMap extends HTMLElement {
| 105 | 132 | |
| 106 | 133 | attributeChangedCallback(name, oldValue, newValue) { |
| 107 | 134 | if (!this.map) return; |
| 108 | − | |
| 135 | + | |
| 109 | 136 | if (name === 'lat' || name === 'lng' || name === 'zoom') { |
| 110 | − const lat = parseFloat(this.getAttribute('lat')) || 39.8283; | |
| 111 | − const lng = parseFloat(this.getAttribute('lng')) || -98.5795; | |
| 112 | − const zoom = parseInt(this.getAttribute('zoom')) || 4; | |
| 113 | − this.map.setView([lat, lng], zoom); | |
| 137 | + this.applyView(); | |
| 114 | 138 | } |
| 115 | 139 | } |
| 116 | 140 | |
| 141 | + // The view attributes are recomputed from the data on every render, so | |
| 142 | + // honouring them unconditionally yanked the map back to the centroid of a | |
| 143 | + // moving fleet several times a minute -- which is what a redraw looked | |
| 144 | + // like even when no marker was touched. | |
| 145 | + applyView(force = false) { | |
| 146 | + if (!this.map || (!force && !this.followView)) return; | |
| 147 | + | |
| 148 | + const lat = parseFloat(this.getAttribute('lat')); | |
| 149 | + const lng = parseFloat(this.getAttribute('lng')); | |
| 150 | + const zoom = parseInt(this.getAttribute('zoom')); | |
| 151 | + | |
| 152 | + if (isNaN(lat) || isNaN(lng)) return; | |
| 153 | + | |
| 154 | + const target = L.latLng(lat, lng); | |
| 155 | + const nextZoom = isNaN(zoom) ? this.map.getZoom() : zoom; | |
| 156 | + | |
| 157 | + if (!force && this.map.getZoom() === nextZoom && !this.viewDriftedFrom(target)) return; | |
| 158 | + | |
| 159 | + this.programmaticView = true; | |
| 160 | + try { | |
| 161 | + this.map.setView(target, nextZoom, { animate: true }); | |
| 162 | + } finally { | |
| 163 | + this.programmaticView = false; | |
| 164 | + } | |
| 165 | + } | |
| 166 | + | |
| 167 | + // A centroid never sits still while vehicles move, so following it exactly | |
| 168 | + // would leave the map permanently drifting. Only move once the target has | |
| 169 | + // left the middle half of the viewport. | |
| 170 | + viewDriftedFrom(target) { | |
| 171 | + const bounds = this.map.getBounds(); | |
| 172 | + const centre = this.map.getCenter(); | |
| 173 | + const latSlack = (bounds.getNorth() - bounds.getSouth()) / 4; | |
| 174 | + const lngSlack = (bounds.getEast() - bounds.getWest()) / 4; | |
| 175 | + | |
| 176 | + return Math.abs(target.lat - centre.lat) > latSlack || | |
| 177 | + Math.abs(target.lng - centre.lng) > lngSlack; | |
| 178 | + } | |
| 179 | + | |
| 180 | + // Both fire synchronously from inside setView, so the flag set around it is | |
| 181 | + // enough to tell our own moves from the user's. movestart rather than | |
| 182 | + // dragstart because keyboard panning never drags. | |
| 183 | + watchForUserView() { | |
| 184 | + const stopFollowing = () => { | |
| 185 | + if (this.programmaticView || !this.followView) return; | |
| 186 | + this.followView = false; | |
| 187 | + this.updateResetControl(); | |
| 188 | + }; | |
| 189 | + | |
| 190 | + this.map.on('movestart', stopFollowing); | |
| 191 | + this.map.on('zoomstart', stopFollowing); | |
| 192 | + } | |
| 193 | + | |
| 194 | + addResetViewControl() { | |
| 195 | + const control = L.control({ position: 'bottomleft' }); | |
| 196 | + | |
| 197 | + control.onAdd = () => { | |
| 198 | + const wrap = L.DomUtil.create('div', 'leaflet-bar leaflet-reset-view'); | |
| 199 | + wrap.innerHTML = ` | |
| 200 | + <button type="button" title="Recentre, and follow the data again"> | |
| 201 | + <svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true"> | |
| 202 | + <circle cx="12" cy="12" r="6.5" fill="none" stroke="currentColor" stroke-width="2.2" /> | |
| 203 | + <path d="M12 1.5v3.5M12 19v3.5M1.5 12h3.5M19 12h3.5" | |
| 204 | + stroke="currentColor" stroke-width="2.2" stroke-linecap="round" /> | |
| 205 | + </svg> | |
| 206 | + <span>Reset view</span> | |
| 207 | + </button> | |
| 208 | + `; | |
| 209 | + | |
| 210 | + L.DomEvent.disableClickPropagation(wrap); | |
| 211 | + wrap.querySelector('button').addEventListener('click', () => { | |
| 212 | + this.followView = true; | |
| 213 | + this.applyView(true); | |
| 214 | + this.updateResetControl(); | |
| 215 | + }); | |
| 216 | + | |
| 217 | + return wrap; | |
| 218 | + }; | |
| 219 | + | |
| 220 | + control.addTo(this.map); | |
| 221 | + this.resetViewControl = control; | |
| 222 | + this.updateResetControl(); | |
| 223 | + } | |
| 224 | + | |
| 225 | + updateResetControl() { | |
| 226 | + const el = this.resetViewControl && this.resetViewControl.getContainer(); | |
| 227 | + if (el) el.style.display = this.followView ? 'none' : ''; | |
| 228 | + } | |
| 229 | + | |
| 117 | 230 | initializeMap() { |
| 118 | 231 | if (this.map) return; // Already initialized |
| 119 | 232 |
@@ -147,7 +260,6 @@ class LeafletMap extends HTMLElement {
| 147 | 260 | linePane.style.pointerEvents = 'none'; |
| 148 | 261 | this.lineRenderer = L.canvas({ pane: 'routeLines', padding: 0.5 }); |
| 149 | 262 | this.linesLayer = L.layerGroup().addTo(this.map); |
| 150 | − this.lines = new Map(); | |
| 151 | 263 | |
| 152 | 264 | // Initialize layer groups for different marker types |
| 153 | 265 | this.queriesLayer = L.layerGroup().addTo(this.map); |
@@ -169,6 +281,10 @@ class LeafletMap extends HTMLElement {
| 169 | 281 | }); |
| 170 | 282 | this.resizeObserver.observe(this.mapElement); |
| 171 | 283 | |
| 284 | + // After the initial setView, so it is not mistaken for a user move. | |
| 285 | + this.watchForUserView(); | |
| 286 | + this.addResetViewControl(); | |
| 287 | + | |
| 172 | 288 | // One more pass after layout settles, for the first paint. |
| 173 | 289 | requestAnimationFrame(() => this.map && this.map.invalidateSize({ animate: false })); |
| 174 | 290 |
@@ -177,20 +293,21 @@ class LeafletMap extends HTMLElement {
| 177 | 293 | |
| 178 | 294 | connectedCallback() { |
| 179 | 295 | // Observer for marker elements being added/removed |
| 296 | + // Reordering a child is one removal and one insertion of the *same* | |
| 297 | + // node, and the two records arrive in whichever order the patch made | |
| 298 | + // them -- so acting on each record in turn either rebuilt the marker | |
| 299 | + // for nothing or, when the addition came first, deleted it outright. | |
| 300 | + // Collect the nodes the batch touched and ask the DOM what became of | |
| 301 | + // each one instead. | |
| 180 | 302 | this.observer = new MutationObserver((mutations) => { |
| 181 | − mutations.forEach((mutation) => { | |
| 182 | − mutation.addedNodes.forEach(node => { | |
| 183 | − if (node.nodeType !== Node.ELEMENT_NODE) return; | |
| 184 | − if (node.tagName === 'LEAFLET-MARKER') this.addMarkerElement(node); | |
| 185 | − if (node.tagName === 'LEAFLET-LINE') this.addLineElement(node); | |
| 186 | − }); | |
| 303 | + const touched = new Set(); | |
| 187 | 304 | |
| 188 | − mutation.removedNodes.forEach(node => { | |
| 189 | − if (node.nodeType !== Node.ELEMENT_NODE) return; | |
| 190 | − if (node.tagName === 'LEAFLET-MARKER') this.removeMarkerElement(node); | |
| 191 | − if (node.tagName === 'LEAFLET-LINE') this.removeLineElement(node); | |
| 192 | − }); | |
| 305 | + mutations.forEach((mutation) => { | |
| 306 | + mutation.addedNodes.forEach(node => this.collectTouched(node, touched)); | |
| 307 | + mutation.removedNodes.forEach(node => this.collectTouched(node, touched)); | |
| 193 | 308 | }); |
| 309 | + | |
| 310 | + touched.forEach(node => this.reconcileNode(node)); | |
| 194 | 311 | }); |
| 195 | 312 | |
| 196 | 313 | this.observer.observe(this, { childList: true, subtree: true }); |
@@ -241,6 +358,29 @@ class LeafletMap extends HTMLElement {
| 241 | 358 | }, 200); |
| 242 | 359 | } |
| 243 | 360 | |
| 361 | + collectTouched(node, touched) { | |
| 362 | + if (node.nodeType !== Node.ELEMENT_NODE) return; | |
| 363 | + if (node.tagName === 'LEAFLET-MARKER' || node.tagName === 'LEAFLET-LINE') touched.add(node); | |
| 364 | + } | |
| 365 | + | |
| 366 | + reconcileNode(node) { | |
| 367 | + const isMarker = node.tagName === 'LEAFLET-MARKER'; | |
| 368 | + | |
| 369 | + if (!this.contains(node)) { | |
| 370 | + isMarker ? this.removeMarkerElement(node) : this.removeLineElement(node); | |
| 371 | + return; | |
| 372 | + } | |
| 373 | + | |
| 374 | + // Still ours: only a node we are not already drawing needs building. A | |
| 375 | + // node that merely moved is identical to the one we hold, and an id | |
| 376 | + // reused by a different element does need rebuilding. | |
| 377 | + if (isMarker) { | |
| 378 | + if (this.markerElements.get(this.markerIdFor(node)) !== node) this.addMarkerElement(node); | |
| 379 | + } else if (this.lines.get(node.getAttribute('id')) === undefined) { | |
| 380 | + this.addLineElement(node); | |
| 381 | + } | |
| 382 | + } | |
| 383 | + | |
| 244 | 384 | disconnectedCallback() { |
| 245 | 385 | if (this.observer) { |
| 246 | 386 | this.observer.disconnect(); |
@@ -295,27 +435,65 @@ class LeafletMap extends HTMLElement {
| 295 | 435 | this.lines.delete(lineId); |
| 296 | 436 | } |
| 297 | 437 | |
| 298 | − // A pure move slides the existing layer, which is far cheaper than | |
| 299 | − // rebuilding it and leaves an open popup open -- worth having when a whole | |
| 300 | − // fleet shifts every refresh. Anything that changes how the marker looks | |
| 301 | − // has to go through addMarkerElement, which recreates it. | |
| 438 | + // What the marker is drawn from. Everything else -- the name, the route, | |
| 439 | + // the vehicle id -- is only ever read back out of the element when a popup | |
| 440 | + // opens, so changing it needs no new layer. | |
| 441 | + static get ICON_ATTRIBUTES() { | |
| 442 | + return new Set(['type', 'tint', 'shape', 'route-type', 'aircraft-class']); | |
| 443 | + } | |
| 444 | + | |
| 445 | + markerIdFor(markerEl) { | |
| 446 | + return markerEl.getAttribute('id') || | |
| 447 | + `${parseFloat(markerEl.getAttribute('lat'))}-${parseFloat(markerEl.getAttribute('lng'))}`; | |
| 448 | + } | |
| 449 | + | |
| 450 | + // Sliding the existing layer is far cheaper than rebuilding it and leaves | |
| 451 | + // an open popup open -- worth having when a whole fleet shifts every | |
| 452 | + // refresh. This used to require that *only* lat and lng changed, which no | |
| 453 | + // moving marker satisfies: vehicles and aircraft report a new bearing with | |
| 454 | + // every position, so each one was torn down and rebuilt on every tick. | |
| 302 | 455 | applyMarkerUpdate(markerEl, changedAttributes) { |
| 303 | 456 | if (!this.map || !markerEl.isConnected) return; |
| 304 | 457 | |
| 305 | − const markerId = markerEl.getAttribute('id'); | |
| 306 | − const existing = markerId && this.markers.get(markerId); | |
| 307 | − const positionOnly = [...changedAttributes].every((name) => name === 'lat' || name === 'lng'); | |
| 458 | + const existing = this.markers.get(this.markerIdFor(markerEl)); | |
| 459 | + if (!existing || !existing.setLatLng) return this.addMarkerElement(markerEl); | |
| 460 | + | |
| 461 | + for (const name of changedAttributes) { | |
| 462 | + if (LeafletMap.ICON_ATTRIBUTES.has(name)) return this.addMarkerElement(markerEl); | |
| 463 | + } | |
| 308 | 464 | |
| 309 | − if (existing && existing.setLatLng && positionOnly) { | |
| 465 | + if (changedAttributes.has('lat') || changedAttributes.has('lng')) { | |
| 310 | 466 | const lat = parseFloat(markerEl.getAttribute('lat')); |
| 311 | 467 | const lng = parseFloat(markerEl.getAttribute('lng')); |
| 312 | − if (!isNaN(lat) && !isNaN(lng)) { | |
| 313 | − existing.setLatLng([lat, lng]); | |
| 314 | − return; | |
| 315 | − } | |
| 468 | + if (isNaN(lat) || isNaN(lng)) return this.addMarkerElement(markerEl); | |
| 469 | + existing.setLatLng([lat, lng]); | |
| 470 | + } | |
| 471 | + | |
| 472 | + if (changedAttributes.has('bearing') && !this.rotateMarker(existing, markerEl)) { | |
| 473 | + return this.addMarkerElement(markerEl); | |
| 474 | + } | |
| 475 | + | |
| 476 | + // The popup builds its content lazily, so a stale one only exists while | |
| 477 | + // it is on screen. | |
| 478 | + if (existing.isPopupOpen && existing.isPopupOpen()) { | |
| 479 | + existing.setPopupContent(this.createPopupContent(markerEl)); | |
| 316 | 480 | } |
| 481 | + } | |
| 482 | + | |
| 483 | + // Turning the part of the icon that points somewhere, rather than building | |
| 484 | + // a whole new icon for a heading change. False means this marker cannot be | |
| 485 | + // turned in place -- an image icon, or a heading appearing or disappearing, | |
| 486 | + // which swaps the symbol itself -- and the caller rebuilds it. | |
| 487 | + rotateMarker(marker, markerEl) { | |
| 488 | + const element = marker.getElement && marker.getElement(); | |
| 489 | + const rotating = element && element.querySelector('[data-rotates]'); | |
| 490 | + if (!rotating) return false; | |
| 491 | + | |
| 492 | + const bearing = parseFloat(markerEl.getAttribute('bearing')); | |
| 493 | + if (isNaN(bearing)) return false; | |
| 317 | 494 | |
| 318 | − this.addMarkerElement(markerEl); | |
| 495 | + rotating.setAttribute('transform', `rotate(${bearing} 12 12)`); | |
| 496 | + return true; | |
| 319 | 497 | } |
| 320 | 498 | |
| 321 | 499 | addMarkerElement(markerEl) { |
@@ -327,7 +505,7 @@ class LeafletMap extends HTMLElement {
| 327 | 505 | |
| 328 | 506 | const lat = parseFloat(markerEl.getAttribute('lat')); |
| 329 | 507 | const lng = parseFloat(markerEl.getAttribute('lng')); |
| 330 | − const markerId = markerEl.getAttribute('id') || `${lat}-${lng}`; | |
| 508 | + const markerId = this.markerIdFor(markerEl); | |
| 331 | 509 | const markerType = markerEl.getAttribute('type') || 'query'; |
| 332 | 510 | |
| 333 | 511 | if (isNaN(lat) || isNaN(lng)) { |
@@ -373,6 +551,7 @@ class LeafletMap extends HTMLElement {
| 373 | 551 | layer.addLayer(leafletMarker); |
| 374 | 552 | this.markers.set(markerId, leafletMarker); |
| 375 | 553 | this.markerElements.set(markerId, markerEl); |
| 554 | + this.markerLayers.set(markerId, layer); | |
| 376 | 555 | |
| 377 | 556 | // Set up click forwarding from Leaflet marker to DOM element |
| 378 | 557 | leafletMarker.on('click', () => { |
@@ -405,17 +584,22 @@ class LeafletMap extends HTMLElement {
| 405 | 584 | } |
| 406 | 585 | |
| 407 | 586 | removeMarkerElement(markerEl) { |
| 408 | − const markerId = markerEl.getAttribute('id') || `${markerEl.getAttribute('lat')}-${markerEl.getAttribute('lng')}`; | |
| 409 | − this.removeMarkerById(markerId); | |
| 587 | + this.removeMarkerById(this.markerIdFor(markerEl)); | |
| 410 | 588 | } |
| 411 | 589 | |
| 412 | 590 | removeMarkerById(markerId) { |
| 413 | 591 | const existingMarker = this.markers.get(markerId); |
| 414 | − if (existingMarker) { | |
| 415 | − this.map.removeLayer(existingMarker); | |
| 416 | − this.markers.delete(markerId); | |
| 417 | − this.markerElements.delete(markerId); | |
| 418 | − } | |
| 592 | + if (!existingMarker) return; | |
| 593 | + | |
| 594 | + // Through the group that holds it: taking it off the map alone leaves | |
| 595 | + // the group's own reference behind, so the group grows without bound | |
| 596 | + // and clearing it later resurrects markers that are long gone. | |
| 597 | + const layer = this.markerLayers.get(markerId); | |
| 598 | + layer ? layer.removeLayer(existingMarker) : this.map.removeLayer(existingMarker); | |
| 599 | + | |
| 600 | + this.markers.delete(markerId); | |
| 601 | + this.markerElements.delete(markerId); | |
| 602 | + this.markerLayers.delete(markerId); | |
| 419 | 603 | } |
| 420 | 604 | |
| 421 | 605 | createOptimizedQueryMarker(lat, lng, markerEl) { |
@@ -518,7 +702,7 @@ class LeafletMap extends HTMLElement {
| 518 | 702 | const pointer = |
| 519 | 703 | bearing === null |
| 520 | 704 | ? '' |
| 521 | − : `<polygon points="12,0.8 15.2,6.4 8.8,6.4" fill="${fill}" stroke="${stroke}" | |
| 705 | + : `<polygon data-rotates points="12,0.8 15.2,6.4 8.8,6.4" fill="${fill}" stroke="${stroke}" | |
| 522 | 706 | stroke-width="1.4" stroke-linejoin="round" |
| 523 | 707 | transform="rotate(${bearing} 12 12)" />`; |
| 524 | 708 |
@@ -565,7 +749,7 @@ class LeafletMap extends HTMLElement {
| 565 | 749 | const body = |
| 566 | 750 | track === null |
| 567 | 751 | ? `<circle cx="12" cy="12" r="5.5" fill="${fill}" stroke="${stroke}" stroke-width="1.4" />` |
| 568 | − : `<polygon points="${plane}" fill="${fill}" stroke="${stroke}" stroke-width="1.2" | |
| 752 | + : `<polygon data-rotates points="${plane}" fill="${fill}" stroke="${stroke}" stroke-width="1.2" | |
| 569 | 753 | stroke-linejoin="round" transform="rotate(${track} 12 12)" />`; |
| 570 | 754 | |
| 571 | 755 | return L.divIcon({ |
@@ -839,6 +1023,7 @@ class LeafletMap extends HTMLElement {
| 839 | 1023 | // Clear the maps as well |
| 840 | 1024 | this.markers.clear(); |
| 841 | 1025 | this.markerElements.clear(); |
| 1026 | + this.markerLayers.clear(); | |
| 842 | 1027 | |
| 843 | 1028 | // Clear the layer groups |
| 844 | 1029 | if (this.queriesLayer) this.queriesLayer.clearLayers(); |
modified
apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+15
−7
@@ -116,13 +116,21 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
| 116 | 116 | |> assign(:map_free_bikes, format_free_bikes(assigns.free_bikes)) |
| 117 | 117 | |> assign(:map_aircraft, format_aircraft(assigns.aircraft)) |
| 118 | 118 | |> assign(:map_stations, format_stations(assigns.stations, Map.get(assigns, :station_statuses, []), Map.get(assigns, :source_tint, nil))) |
| 119 | − groups = [ | |
| 120 | − assigns.map_queries, | |
| 121 | − assigns.map_vehicles, | |
| 122 | − assigns.map_aircraft, | |
| 123 | − assigns.map_free_bikes, | |
| 124 | − assigns.map_stations | |
| 125 | − ] | |
| 119 | + # Sorted, and by a stable key: within a group the order is whatever the feed | |
| 120 | + # or the query happened to return, and a marker that changes place in the | |
| 121 | + # list is a DOM node LiveView *moves* -- which the map element sees as that | |
| 122 | + # marker being removed and a new one added, and rebuilds it for nothing. | |
| 123 | + groups = | |
| 124 | + Enum.map( | |
| 125 | + [ | |
| 126 | + assigns.map_queries, | |
| 127 | + assigns.map_vehicles, | |
| 128 | + assigns.map_aircraft, | |
| 129 | + assigns.map_free_bikes, | |
| 130 | + assigns.map_stations | |
| 131 | + ], | |
| 132 | + fn points -> Enum.sort_by(points, &to_string(&1.id)) end | |
| 133 | + ) | |
| 126 | 134 | all_points = Enum.concat(groups) |
| 127 | 135 | # Centre on everything, even any points a cap would drop -- unless the |
| 128 | 136 | # caller named a place to look at. |
Parents: c319a5a