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.
A fullscreen toggle on every map
b060813 · Gabriel Morell · 2026-09-01 02:46
Message
{commit_body(@commit)}
Files changed
modified
apps/room_sanctum/assets/js/app.js
+9
−0
@@ -48,6 +48,7 @@ window.Base64 = Base64;
| 48 | 48 | import L from 'leaflet' |
| 49 | 49 | import 'leaflet-centermarker' |
| 50 | 50 | import { addBasemap } from './leaflet/basemap' |
| 51 | +import { addFullscreenControl } from './leaflet/fullscreen' | |
| 51 | 52 | import LeafletMap from './hooks/leaflet_map.js' |
| 52 | 53 | |
| 53 | 54 | import LeafletMapBridge from './hooks/leaflet_map_bridge.js' |
@@ -158,6 +159,7 @@ Hooks.mkMap = {
| 158 | 159 | centerMarker.addTo(map); |
| 159 | 160 | |
| 160 | 161 | addBasemap(map, { maxZoom: 19 }); |
| 162 | + addFullscreenControl(map); | |
| 161 | 163 | |
| 162 | 164 | // Search functionality |
| 163 | 165 | const searchInput = document.getElementById('location-search'); |
@@ -326,6 +328,7 @@ Hooks.mkTesterMap = {
| 326 | 328 | // marker.addTo(map); |
| 327 | 329 | |
| 328 | 330 | addBasemap(map, { maxZoom: 19 }); |
| 331 | + addFullscreenControl(map); | |
| 329 | 332 | |
| 330 | 333 | this.handleEvent("add_marker", ({lat, lon}) => { |
| 331 | 334 | const marker = L.marker(L.latLng(lat, lon)) |
@@ -365,6 +368,12 @@ Hooks.mkShowMap = {
| 365 | 368 | } |
| 366 | 369 | |
| 367 | 370 | addBasemap(map, { maxZoom: 19 }); |
| 371 | + | |
| 372 | + // The show map is deliberately inert in the page -- it is an | |
| 373 | + // illustration next to the record, not something to explore. Filling | |
| 374 | + // the screen is the one moment that stops being true, so panning and | |
| 375 | + // zooming come back for as long as it lasts. | |
| 376 | + addFullscreenControl(map, { interactive: true }); | |
| 368 | 377 | } |
| 369 | 378 | } |
| 370 | 379 |
modified
apps/room_sanctum/assets/js/hooks/leaflet_map.js
+11
−0
@@ -7,6 +7,7 @@
| 7 | 7 | // L itself comes from the global the Leaflet UMD bundle installs; this one is |
| 8 | 8 | // ours, so it has to be imported. |
| 9 | 9 | import { addBasemap } from '../leaflet/basemap' |
| 10 | +import { addFullscreenControl } from '../leaflet/fullscreen' | |
| 10 | 11 | |
| 11 | 12 | const LeafletMap = { |
| 12 | 13 | mounted() { |
@@ -66,6 +67,11 @@ const LeafletMap = {
| 66 | 67 | this.basemap.remove(); |
| 67 | 68 | this.basemap = null; |
| 68 | 69 | } |
| 70 | + // Same story: it holds document-level listeners of its own. | |
| 71 | + if (this.fullscreen) { | |
| 72 | + this.fullscreen.remove(); | |
| 73 | + this.fullscreen = null; | |
| 74 | + } | |
| 69 | 75 | }, |
| 70 | 76 | |
| 71 | 77 | initializeMap() { |
@@ -106,6 +112,11 @@ const LeafletMap = {
| 106 | 112 | // Monochrome tiles, washed in the active theme colour. |
| 107 | 113 | this.basemap = addBasemap(this.map, { maxZoom: 18 }); |
| 108 | 114 | |
| 115 | + // this.el, not the map container: the container is absolutely positioned | |
| 116 | + // inside it, so fullscreening the container alone would leave it sized to | |
| 117 | + // an element still laid out at its old size. | |
| 118 | + this.fullscreen = addFullscreenControl(this.map, { target: this.el }); | |
| 119 | + | |
| 109 | 120 | // Initialize separate layer groups for queries, vehicles, free bikes, and stations |
| 110 | 121 | this.queriesLayer = L.layerGroup().addTo(this.map); |
| 111 | 122 | this.vehiclesLayer = L.layerGroup().addTo(this.map); |
added
apps/room_sanctum/assets/js/leaflet/fullscreen.js
+248
−0
@@ -0,0 +1,248 @@
| 1 | +import L from 'leaflet' | |
| 2 | + | |
| 3 | +// A fullscreen toggle for any Leaflet map on the page. | |
| 4 | +// | |
| 5 | +// The maps are built five different ways -- three hooks in app.js, the | |
| 6 | +// dashboard hook, and the <leaflet-map> web component -- so this takes a map | |
| 7 | +// and the element that should fill the screen, and knows nothing else about | |
| 8 | +// its caller. | |
| 9 | +// | |
| 10 | +// Two mechanisms, in order of preference: | |
| 11 | +// | |
| 12 | +// The Fullscreen API, which gives a real fullscreen window with the browser | |
| 13 | +// chrome gone. It can be refused -- iOS Safari has never implemented it for | |
| 14 | +// elements, and an iframe without allowfullscreen is denied -- and the | |
| 15 | +// refusal arrives as a rejected promise rather than a missing method, so it | |
| 16 | +// cannot be feature-detected up front. | |
| 17 | +// | |
| 18 | +// Failing that, a fixed-position "maximised" class covering the viewport. | |
| 19 | +// Not as good (the browser chrome stays) but it works everywhere, and it is | |
| 20 | +// the same button either way. | |
| 21 | +const STYLE_MARK = 'data-leaflet-fullscreen-style' | |
| 22 | + | |
| 23 | +// The maximised fallback sits above the page but below nothing else in | |
| 24 | +// particular; 9999 clears the sticky navbar, which is the only thing on these | |
| 25 | +// pages that competes. | |
| 26 | +const CSS = ` | |
| 27 | +.leaflet-fullscreen-maximised { | |
| 28 | + position: fixed !important; | |
| 29 | + inset: 0 !important; | |
| 30 | + width: 100% !important; | |
| 31 | + height: 100% !important; | |
| 32 | + max-width: none !important; | |
| 33 | + max-height: none !important; | |
| 34 | + margin: 0 !important; | |
| 35 | + border-radius: 0 !important; | |
| 36 | + z-index: 9999 !important; | |
| 37 | +} | |
| 38 | +/* The UA sizes the fullscreen element itself, but these maps carry a height | |
| 39 | + from a utility class (h-96) or an inline style, and browsers do not agree on | |
| 40 | + whether the UA rule outranks those. Saying it here settles it: without this | |
| 41 | + the map keeps its 400px and the rest of the screen is black. */ | |
| 42 | +.leaflet-fullscreen-target:fullscreen { | |
| 43 | + width: 100% !important; | |
| 44 | + height: 100% !important; | |
| 45 | +} | |
| 46 | +:fullscreen .leaflet-container, | |
| 47 | +:fullscreen.leaflet-container { | |
| 48 | + width: 100% !important; | |
| 49 | + height: 100% !important; | |
| 50 | +} | |
| 51 | +.leaflet-fullscreen button { | |
| 52 | + display: block; | |
| 53 | + width: 30px; | |
| 54 | + height: 30px; | |
| 55 | + padding: 0; | |
| 56 | + line-height: 0; | |
| 57 | + color: #334155; | |
| 58 | + background: #fff; | |
| 59 | + border: none; | |
| 60 | + border-radius: 4px; | |
| 61 | + cursor: pointer; | |
| 62 | +} | |
| 63 | +.leaflet-fullscreen button:hover { | |
| 64 | + background: #f1f5f9; | |
| 65 | +} | |
| 66 | +.leaflet-fullscreen svg { | |
| 67 | + display: inline-block; | |
| 68 | + vertical-align: middle; | |
| 69 | +} | |
| 70 | +` | |
| 71 | + | |
| 72 | +// Styles go into whichever root the map lives in -- the document for the hook | |
| 73 | +// maps, the shadow root for the web component, which document stylesheets | |
| 74 | +// cannot reach -- and, for a shadow root, into the document as well: the | |
| 75 | +// button is inside the shadow tree but the element that takes the maximised | |
| 76 | +// class is the host, which sits in the document and is styled by it. | |
| 77 | +function injectStyleInto(host) { | |
| 78 | + if (!host || host.querySelector(`style[${STYLE_MARK}]`)) return | |
| 79 | + const style = document.createElement('style') | |
| 80 | + style.setAttribute(STYLE_MARK, '') | |
| 81 | + style.textContent = CSS | |
| 82 | + host.appendChild(style) | |
| 83 | +} | |
| 84 | + | |
| 85 | +function injectStyle(root) { | |
| 86 | + injectStyleInto(root === document ? document.head : root) | |
| 87 | + if (root !== document) injectStyleInto(document.head) | |
| 88 | +} | |
| 89 | + | |
| 90 | +const ENTER_ICON = ` | |
| 91 | + <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" | |
| 92 | + stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"> | |
| 93 | + <path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6" /> | |
| 94 | + </svg>` | |
| 95 | + | |
| 96 | +const EXIT_ICON = ` | |
| 97 | + <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" | |
| 98 | + stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"> | |
| 99 | + <path d="M9 3v6H3M15 3v6h6M9 21v-6H3M15 21v-6h6" /> | |
| 100 | + </svg>` | |
| 101 | + | |
| 102 | +// Safari still needs the prefixed names. | |
| 103 | +function fullscreenElement(doc) { | |
| 104 | + return doc.fullscreenElement || doc.webkitFullscreenElement || null | |
| 105 | +} | |
| 106 | + | |
| 107 | +function requestFullscreen(el) { | |
| 108 | + const request = el.requestFullscreen || el.webkitRequestFullscreen | |
| 109 | + if (!request) return Promise.reject(new Error('unsupported')) | |
| 110 | + // Chrome's prefixed version returns undefined rather than a promise. | |
| 111 | + return Promise.resolve(request.call(el, { navigationUI: 'hide' })) | |
| 112 | +} | |
| 113 | + | |
| 114 | +function exitFullscreen(doc) { | |
| 115 | + const exit = doc.exitFullscreen || doc.webkitExitFullscreen | |
| 116 | + return exit ? Promise.resolve(exit.call(doc)) : Promise.resolve() | |
| 117 | +} | |
| 118 | + | |
| 119 | +// The interactions a static map turns off. Fullscreen is the one place they | |
| 120 | +// are worth having back: a map filling the screen that cannot be panned is | |
| 121 | +// just a bigger picture. | |
| 122 | +const HANDLERS = ['dragging', 'touchZoom', 'scrollWheelZoom', 'doubleClickZoom', 'boxZoom', 'keyboard'] | |
| 123 | + | |
| 124 | +/** | |
| 125 | + * Add a fullscreen toggle to `map`. | |
| 126 | + * | |
| 127 | + * Options: | |
| 128 | + * target element to fill the screen; defaults to the map's container. | |
| 129 | + * Pass the outer element where the map sits in a wrapper -- the | |
| 130 | + * shadow host, or the hook's own element. | |
| 131 | + * position Leaflet control position (default 'topleft', under the zoom | |
| 132 | + * control) | |
| 133 | + * interactive true to enable panning and zooming while fullscreen on a map | |
| 134 | + * whose interactions are otherwise off, restoring them on exit | |
| 135 | + * title button tooltip (default 'Fullscreen') | |
| 136 | + */ | |
| 137 | +export function addFullscreenControl(map, opts = {}) { | |
| 138 | + const container = map.getContainer() | |
| 139 | + const target = opts.target || container | |
| 140 | + const doc = container.ownerDocument | |
| 141 | + const root = container.getRootNode ? container.getRootNode() : document | |
| 142 | + | |
| 143 | + injectStyle(root) | |
| 144 | + target.classList.add('leaflet-fullscreen-target') | |
| 145 | + | |
| 146 | + let maximised = false | |
| 147 | + let restoreHandlers = null | |
| 148 | + let button = null | |
| 149 | + | |
| 150 | + const isOn = () => maximised || fullscreenElement(doc) === target | |
| 151 | + | |
| 152 | + const paint = () => { | |
| 153 | + if (!button) return | |
| 154 | + const on = isOn() | |
| 155 | + button.innerHTML = on ? EXIT_ICON : ENTER_ICON | |
| 156 | + button.title = on ? 'Exit fullscreen' : opts.title || 'Fullscreen' | |
| 157 | + button.setAttribute('aria-label', button.title) | |
| 158 | + button.setAttribute('aria-pressed', on ? 'true' : 'false') | |
| 159 | + } | |
| 160 | + | |
| 161 | + // Leaflet measured the container at its old size; nothing lays out | |
| 162 | + // correctly until it measures again. rAF so the browser has applied the | |
| 163 | + // new box first. | |
| 164 | + const resize = () => { | |
| 165 | + requestAnimationFrame(() => map.invalidateSize({ animate: false })) | |
| 166 | + } | |
| 167 | + | |
| 168 | + const setInteraction = (on) => { | |
| 169 | + if (!opts.interactive) return | |
| 170 | + if (on) { | |
| 171 | + restoreHandlers = HANDLERS.filter((name) => map[name] && !map[name].enabled()) | |
| 172 | + restoreHandlers.forEach((name) => map[name].enable()) | |
| 173 | + } else if (restoreHandlers) { | |
| 174 | + restoreHandlers.forEach((name) => map[name] && map[name].disable()) | |
| 175 | + restoreHandlers = null | |
| 176 | + } | |
| 177 | + } | |
| 178 | + | |
| 179 | + const settle = () => { | |
| 180 | + setInteraction(isOn()) | |
| 181 | + paint() | |
| 182 | + resize() | |
| 183 | + } | |
| 184 | + | |
| 185 | + // Only for the fallback; a real fullscreen exit is the browser's own. | |
| 186 | + const onKeydown = (e) => { | |
| 187 | + if (e.key === 'Escape' && maximised) { | |
| 188 | + e.preventDefault() | |
| 189 | + maximise(false) | |
| 190 | + } | |
| 191 | + } | |
| 192 | + | |
| 193 | + function maximise(on) { | |
| 194 | + maximised = on | |
| 195 | + target.classList.toggle('leaflet-fullscreen-maximised', on) | |
| 196 | + if (on) doc.addEventListener('keydown', onKeydown) | |
| 197 | + else doc.removeEventListener('keydown', onKeydown) | |
| 198 | + settle() | |
| 199 | + } | |
| 200 | + | |
| 201 | + const toggle = () => { | |
| 202 | + if (maximised) return maximise(false) | |
| 203 | + | |
| 204 | + if (fullscreenElement(doc) === target) { | |
| 205 | + exitFullscreen(doc).catch(() => {}) | |
| 206 | + return | |
| 207 | + } | |
| 208 | + | |
| 209 | + requestFullscreen(target).catch(() => maximise(true)) | |
| 210 | + } | |
| 211 | + | |
| 212 | + // Both spellings: Safari fires only the prefixed one. | |
| 213 | + doc.addEventListener('fullscreenchange', settle) | |
| 214 | + doc.addEventListener('webkitfullscreenchange', settle) | |
| 215 | + | |
| 216 | + const control = L.control({ position: opts.position || 'topleft' }) | |
| 217 | + | |
| 218 | + control.onAdd = () => { | |
| 219 | + const wrap = L.DomUtil.create('div', 'leaflet-bar leaflet-fullscreen') | |
| 220 | + button = L.DomUtil.create('button', '', wrap) | |
| 221 | + button.type = 'button' | |
| 222 | + paint() | |
| 223 | + | |
| 224 | + // Without this a click on the button also reaches the map underneath, | |
| 225 | + // which reads it as a double click and zooms in behind the change. | |
| 226 | + L.DomEvent.disableClickPropagation(wrap) | |
| 227 | + L.DomEvent.on(button, 'click', toggle) | |
| 228 | + | |
| 229 | + return wrap | |
| 230 | + } | |
| 231 | + | |
| 232 | + control.addTo(map) | |
| 233 | + | |
| 234 | + return { | |
| 235 | + control, | |
| 236 | + isFullscreen: isOn, | |
| 237 | + toggle, | |
| 238 | + remove() { | |
| 239 | + if (maximised) maximise(false) | |
| 240 | + target.classList.remove('leaflet-fullscreen-target') | |
| 241 | + doc.removeEventListener('fullscreenchange', settle) | |
| 242 | + doc.removeEventListener('webkitfullscreenchange', settle) | |
| 243 | + doc.removeEventListener('keydown', onKeydown) | |
| 244 | + control.remove() | |
| 245 | + button = null | |
| 246 | + } | |
| 247 | + } | |
| 248 | +} |
modified
apps/room_sanctum/assets/js/leaflet/leaflet-map.js
+11
−0
@@ -5,6 +5,7 @@ import L from 'leaflet'
| 5 | 5 | // rules and lay themselves out as a grid of loose images. |
| 6 | 6 | import leafletCss from 'leaflet/dist/leaflet.css' |
| 7 | 7 | import { addBasemap } from './basemap' |
| 8 | +import { addFullscreenControl } from './fullscreen' | |
| 8 | 9 | |
| 9 | 10 | const template = document.createElement('template'); |
| 10 | 11 | template.innerHTML = ` |
@@ -285,6 +286,11 @@ class LeafletMap extends HTMLElement {
| 285 | 286 | this.watchForUserView(); |
| 286 | 287 | this.addResetViewControl(); |
| 287 | 288 | |
| 289 | + // The host, not the map container: the container lives in the shadow | |
| 290 | + // tree, and fullscreening it would take the map away from the element | |
| 291 | + // whose height it is sized against. | |
| 292 | + this.fullscreen = addFullscreenControl(this.map, { target: this }); | |
| 293 | + | |
| 288 | 294 | // One more pass after layout settles, for the first paint. |
| 289 | 295 | requestAnimationFrame(() => this.map && this.map.invalidateSize({ animate: false })); |
| 290 | 296 |
@@ -395,6 +401,11 @@ class LeafletMap extends HTMLElement {
| 395 | 401 | this.basemap.remove(); |
| 396 | 402 | this.basemap = null; |
| 397 | 403 | } |
| 404 | + // Likewise: it listens on the document, not on the map. | |
| 405 | + if (this.fullscreen) { | |
| 406 | + this.fullscreen.remove(); | |
| 407 | + this.fullscreen = null; | |
| 408 | + } | |
| 398 | 409 | if (this.map) { |
| 399 | 410 | this.map.remove(); |
| 400 | 411 | } |
Parents: 608b1f7