PageSourceSearch

https://bikeindex.org/assets/controllers/registrations/show/parkin…d0bd1e21b93a4b3378bf4bb71a7bf3d487694d3e.js

js bikeindex.org collected 2026-10-01 10:24:47 UTC 15,881 bytes, 375 lines download raw bytes

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.