Patch the map instead of rebuilding it every refresh

608b1f7 · Gabriel Morell · 2026-08-31 15:28

10 files +240 -47
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