1import { Controller } from '@hotwired/stimulus' 2import { collapse, COLLAPSE_DURATION_MS } from 'utils/collapse_utils' 3import { ExpandControl, groundRadiusStops, loadMapLibre, MAPS_STYLE_URL, OSM_ATTRIBUTION, showMapUnavailable } from 'utils/maplibre' 4 5/* global navigator */ 6 7// Connects to data-controller='registrations--show--parking-notification-form' 8// The whole org-admin parking-notification panel: the accordion says which trigger 9// opened it, which retitles the heading and preselects the impound kind. "Set on 10// map" mode shows a map under a fixed centre pin: it seeds from the browser 11// location (falling back to the organization's location), stamps the coordinates 12// onto the form, and keeps the pin + zoom in the URL so a reload restores them. 13// Moving the map moves the pin. "Enter address manually" reveals the 14// UI::Forms::AddressGroup fields instead. Tiles come from our self-hosted MapLibre 15// basemap. 16const DEFAULT_ZOOM = 15.5 17 18// The device's own position, so a dragged pin can be judged against where the 19// phone thinks it is. The halo covers the accuracy the browser reported. 20const DEVICE_ACCURACY_PAINT = { 21 'circle-color': '#2563eb', 22 'circle-opacity': 0.15 23} 24 25const DEVICE_DOT_PAINT = { 26 'circle-radius': 5, 27 'circle-color': '#2563eb', 28 'circle-stroke-width': 2, 29 'circle-stroke-color': 'white' 30} 31 32export default class extends Controller { 33 static targets = ['latitude', 'longitude', 'accuracy', 'submit', 'addressGroup', 34 'useEnteredAddress', 'heading', 'locationMode', 'kindGroup', 'locationSection', 35 'mapSection', 'mapFrame', 'map', 'mapUnavailable'] 36 37 static values = { 38 notificationHeading: String, 39 impoundHeading: String, 40 defaultKind: String, 41 orgLatitude: Number, 42 orgLongitude: Number 43 } 44 45 // The accordion opens a panel as soon as its own module lands, so `shown` can be 46 // spent before this lazily loaded one arrives â it records the name it opened as 47 connect () { 48 if (this.element.dataset.openedAs) this.applyMode(this.element.dataset.openedAs) 49 } 50 51 panelShown (event) { 52 this.applyMode(event.detail?.name) 53 } 54 55 applyMode (name) { 56 const impound = name === 'impound' 57 if (this.hasHeadingTarget) { 58 this.headingTarget.textContent = impound ? this.impoundHeadingValue : this.notificationHeadingValue 59 } 60 const radio = this.element.querySelector(`input[name$="[kind]"][value="${impound ? 'impound_notification' : this.defaultKindValue}"]`) 61 if (radio) radio.checked = true 62 // Impound preselects the kind, so hide the "Notification because" chooser 63 if (this.hasKindGroupTarget) this.toggle(this.kindGroupTarget, !impound) 64 // A recent earlier notification preselects "repeat", so sync on open (no animation) 65 this.applyRepeat(this.repeatSelected, 0) 66 } 67 68 // Not collapse()'s default by another name: applyRepeat forwards this to startLocation, 69 // which defaults to 0 and would resize the map before the reveal finished 70 selectRepeat (event) { 71 this.applyRepeat(event.target.value === 'true', COLLAPSE_DURATION_MS) 72 } 73 74 get repeatSelected () { 75 return this.element.querySelector("input[name$='[is_repeat]'][value='true']")?.checked || false 76 } 77 78 // A repeat reuses the earlier notification's location, so collapse the location 79 // controls; a hidden required field would otherwise block submit. Deferring the 80 // map until the controls are actually revealed keeps a repeat from loading tiles 81 // (and burning a geocode) for a map nobody sees 82 applyRepeat (repeat, duration) { 83 if (this.hasLocationSectionTarget) collapse(repeat ? 'hide' : 'show', this.locationSectionTarget, duration) 84 if (repeat) { 85 this.setManualRequired(false) 86 this.enableSubmit() 87 } else if (!this.manualMode) { 88 this.startLocation(duration) 89 } 90 } 91 92 // Segmented control: "current" places a pin on the map, "entered" reveals the 93 // address fields 94 selectLocationMode (event) { 95 if (event.target.value === 'entered') this.enterManually(COLLAPSE_DURATION_MS) 96 else this.startLocation(COLLAPSE_DURATION_MS) 97 } 98 99 // Reflect the chosen mode across the radios, the hidden flag, the required 100 // fields and which of the map / address panels is showing 101 applyLocationMode (manual, duration) { 102 const value = manual ? 'entered' : 'current'
103 this.locationModeTargets.forEach((radio) => { radio.checked = radio.value === value }) 104 if (this.hasUseEnteredAddressTarget) this.useEnteredAddressTarget.value = manual 105 this.setManualRequired(manual) 106 collapse(manual ? 'show' : 'hide', this.addressGroupTarget, duration) 107 collapse(manual ? 'hide' : 'show', this.mapSectionTarget, duration) 108 } 109 110 // Show the map and seed the pin. Once a location has resolved this session the 111 // pin is already placed, so just re-reveal the map. 112 startLocation (revealDuration = 0) { 113 this.applyLocationMode(false, revealDuration) 114 115 if (this.located) { 116 // The frame may still be mid-collapse, so measure once it has settled 117 window.setTimeout(() => this.map?.resize(), revealDuration) 118 return 119 } 120 121 const stored = this.storedMapState 122 if (stored) { 123 this.locate(stored.latitude, stored.longitude, { zoom: stored.zoom }) 124 } else { 125 // The org location makes the form submittable at once; the map itself waits 126 // for the device fix so it doesn't load tiles it's about to pan away from 127 this.setCoordinates(this.orgLatitudeValue, this.orgLongitudeValue) 128 this.requestLocation() 129 } 130 } 131 132 // A high-accuracy fix can take the full timeout, and reopening the panel or 133 // toggling modes re-enters this â one acquisition at a time 134 requestLocation () { 135 if (this.locating) return 136 if (!navigator.geolocation) return this.locationFailed() 137 this.locating = true 138 navigator.geolocation.getCurrentPosition( 139 (position) => { this.locating = false; this.locationFound(position) }, 140 () => { this.locating = false; this.locationFailed() }, 141 { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 } 142 ) 143 } 144 145 locationFound (position) { 146 if (this.manualMode) return // they switched to manual entry while we waited 147 const { latitude, longitude, accuracy } = position.coords 148 this.deviceLocation = position.coords 149 this.locate(latitude, longitude, { accuracy }) 150 this.renderDeviceLocation() 151 } 152 153 // Mark where the device actually is, distinct from the pin the user places. Only 154 // a real fix is drawn â the org fallback isn't "your location" 155 renderDeviceLocation () { 156 if (!this.mapLoaded || !this.deviceLocation) return 157 const { latitude, longitude, accuracy } = this.deviceLocation 158 const data = { type: 'Feature', geometry: { type: 'Point', coordinates: [longitude, latitude] } } 159 const radius = groundRadiusStops(accuracy || 0, latitude) 160 161 const source = this.map.getSource('device-location') 162 if (source) { 163 source.setData(data) 164 } else { 165 this.map.addSource('device-location', { type: 'geojson', data }) 166 this.map.addLayer({ id: 'device-accuracy', type: 'circle', source: 'device-location', paint: DEVICE_ACCURACY_PAINT }) 167 this.map.addLayer({ id: 'device-dot', type: 'circle', source: 'device-location', paint: DEVICE_DOT_PAINT }) 168 } 169 this.map.setPaintProperty('device-accuracy', 'circle-radius', radius) 170 } 171 172 locationFailed () { 173 if (this.manualMode) return 174 // Fall back to the org location; the user drags the map to adjust it 175 this.locate(this.orgLatitudeValue, this.orgLongitudeValue, { chosen: false }) 176 } 177 178 // `chosen` is false for the org fallback: it's somewhere to point the map, not a 179 // spot anyone picked, so it stays out of the URL and out of the address fields 180 locate (latitude, longitude, { zoom = DEFAULT_ZOOM, accuracy = '', chosen = true } = {}) { 181 this.located = true 182 this.pinChosen = chosen 183 this.setCoordinates(latitude, longitude, accuracy) 184 this.pinZoom = zoom 185 this.syncMap() 186 this.persistMapState() 187 } 188 189 // The map settled after the user moved it (or tapped the geolocate button); the 190 // fixed pin sits at the centre, so adopt that as the location 191 centerAdopted () { 192 if (!this.map) return 193 const { lat, lng } = this.map.getCenter() 194 if (this.centerMoved(lat, lng)) { 195 this.pinChosen = true 196 this.setCoordinates(lat, lng) 197 } 198 this.persistMapState() // also captures a zoom change that left the centre put 199 } 200 201 // Whether the map centre differs from the coordinates already on the form â 202 // true after a user pan, false after our own recenter or a pure zoom 203 centerMoved (latitude, longitude) { 204 return Math.abs(this.pinLatitude - latitude) > 1e-5 || Math.abs(this.pinLongitude - longitude) > 1e-5 205 } 206 207 // The hidden fields are the source of truth for the coordinates 208 setCoordinates (latitude, longitude, accuracy = '') { 209 this.latitudeTarget.value = latitude 210 this.longitudeTarget.value = longitude 211 this.accuracyTarget.value = accuracy 212 this.enableSubmit() 213 } 214 215 get pinLatitude () { return parseFloat(this.latitudeTarget.value) } 216 get pinLongitude () { return parseFloat(this.longitudeTarget.value) } 217 get pinKey () { return `${this.pinLatitude},${this.pinLongitude}` } 218 219 syncMap () { 220 if (!this.hasMapTarget) return 221 if (this.map) { 222 this.map.easeTo({ center: [this.pinLongitude, this.pinLatitude], zoom: this.pinZoom }) 223 } else { 224 this.buildMap() 225 } 226 } 227 228 async buildMap () { 229 if (this.mapLoading) return 230 this.mapLoading = true 231 try { 232 const maplibregl = await loadMapLibre() 233 if (!this.element.isConnected) return 234 235 // Read the coordinates now (not when buildMap was queued) so a fix that 236 // arrived while MapLibre was loading is reflected in the initial center 237 const center = [this.pinLongitude, this.pinLatitude] 238 this.map = new maplibregl.Map({ 239 container: this.mapTarget, 240 style: MAPS_STYLE_URL, 241 center, 242 zoom: this.pinZoom, 243 maxZoom: 18, 244 attributionControl: { customAttribution: OSM_ATTRIBUTION } 245 }) 246 this.map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right') 247 this.map.addControl(new ExpandControl(), 'top-right') 248 249 // showUserLocation off: we draw the device marker ourselves, and two of them 250 // drift apart the moment the control gets a newer fix than our own 251 const geolocate = new maplibregl.GeolocateControl({ 252 positionOptions: { enableHighAccuracy: true }, 253 showUserLocation: false 254 }) 255 this.map.addControl(geolocate, 'top-right') 256 // The control re-centres the map (which moves the pin via moveend); adopt its 257 // fresher fix for the marker too 258 geolocate.on('geolocate', (position) => { 259 this.deviceLocation = position.coords 260 this.renderDeviceLocation() 261 }) 262 263 this.map.on('moveend', () => this.centerAdopted()) 264 this.map.on('load', () => { 265 this.mapLoaded = true 266 this.map.resize() // the container may have been collapsed when the map was built 267 this.renderDeviceLocation() // a fix that landed before the style was ready 268 }) 269 } catch (error) { 270 // The coordinates are already stamped, so the form still submits 271 showMapUnavailable(error, { 272 source: this.identifier, 273 map: this.map, 274 canvas: this.mapFrameTarget, 275 message: this.hasMapUnavailableTarget ? this.mapUnavailableTarget : null 276 }) 277 this.map = null 278 } 279 } 280 281 disconnect () { 282 this.map?.remove() 283 this.map = null 284 this.mapLoaded = false // a pending geolocation callback must not touch the removed map 285 } 286 287 // The pin + zoom live in the URL so a reload (or shared link) restores them. An 288 // unchosen pin isn't worth restoring, and restoring it would make it look chosen 289 persistMapState () { 290 if (!this.pinChosen) return 291 const url = new URL(window.location) 292 url.searchParams.set('map_lat', this.pinLatitude.toFixed(6)) 293 url.searchParams.set('map_lng', this.pinLongitude.toFixed(6)) 294 url.searchParams.set('map_zoom', (this.map?.getZoom() ?? this.pinZoom).toFixed(2)) 295 // Revealing the map fires a moveend that changed nothing; skip the no-op write 296 if (url.search === window.location.search) return 297 window.history.replaceState(window.history.state, '', url) 298 } 299 300 get storedMapState () { 301 const params = new URLSearchParams(window.location.search) 302 const latitude = parseFloat(params.get('map_lat')) 303 const longitude = parseFloat(params.get('map_lng')) 304 if (Number.isNaN(latitude) || Number.isNaN(longitude)) return null 305 const zoom = parseFloat(params.get('map_zoom')) 306 return { latitude, longitude, zoom: Number.isNaN(zoom) ? DEFAULT_ZOOM : zoom } 307 } 308 309 // Split the pin's coordinates into address parts, to seed the manual-entry 310 // fields. Marked before the request so toggling modes doesn't re-ask for a 311 // spot we've already looked up. Our endpoint resolves the country and region the 312 // way the server does, so what we prefill is what a save would store 313 async reverseGeocode () { 314 this.geocodedFor = this.pinKey 315 // What the fields hold now â anything typed while we wait is theirs, not ours to replace 316 const replaceable = new Map([...this.addressGroupTarget.querySelectorAll('[name]')].map((field) => [field, field.value])) 317 try { 318 const url = `/reverse_geocode?latitude=${this.pinLatitude}&longitude=${this.pinLongitude}` 319 // Accept json so a rack-attack 429 comes back as json rather than plain text 320 const response = await window.fetch(url, { headers: { Accept: 'application/json' } }) 321 if (!response.ok) return 322 this.geocodedAddress = await response.json() 323 this.fillAddress(replaceable) 324 } catch { /* leave the fields for them to fill in */ } 325 } 326 327 enterManually (duration = 0) { 328 this.applyLocationMode(true, duration) 329 // Seed from the resolved address while it still describes the pin, without 330 // waiting on the network; a pin that has moved gets a fresh geocode instead
331 if (this.geocodedFor === this.pinKey) this.fillAddress() 332 else if (this.pinChosen) this.reverseGeocode() 333 this.enableSubmit() 334 } 335 336 // Whether "enter address manually" is the selected mode 337 get manualMode () { 338 return this.locationModeTargets.some((radio) => radio.value === 'entered' && radio.checked) 339 } 340 341 addressField (attribute) { 342 return this.addressGroupTarget.querySelector(`[name$='[${attribute}]']`) 343 } 344 345 // Write the located place into the AddressGroup fields, which the endpoint names 346 // its response after. A blank field is always filled; a field with something in it 347 // only when `replaceable` says it still holds what the geocode was asked against. 348 fillAddress (replaceable = new Map()) { 349 const address = this.geocodedAddress 350 if (!address) return 351 const country = this.addressField('country_id') 352 const countryBefore = country?.value 353 Object.entries(address).forEach(([attribute, value]) => { 354 const field = this.addressField(attribute) 355 if (!field || !value) return 356 if (field.value && field.value !== replaceable.get(field)) return 357 field.value = value 358 }) 359 // Let AddressGroup swap the state select for the free-text region field. Only on 360 // an actual change â it clears the state select for a non-US country 361 if (country && country.value !== countryBefore) country.dispatchEvent(new Event('change', { bubbles: true })) 362 } 363 364 // Manual entry requires the street and city; the map pin doesn't 365 setManualRequired (required) { 366 this.element.querySelectorAll("input[name$='[street]'], input[name$='[city]']") 367 .forEach((field) => { field.required = required }) 368 } 369 370 enableSubmit () { 371 if (this.hasSubmitTarget) this.submitTarget.disabled = false 372 } 373 374 toggle (el, visible) { if (el) el.classList.toggle('tw:hidden', !visible) } 375};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.