1/** 2 * Provider-neutral place search client. 3 * 4 * Provider responses are normalized to: 5 * { id, label, primary, secondary, lat, lng, bounds, components } 6 */ 7(function (window) { 8 'use strict'; 9 10 if (window.jrMapsLocationSearch) return; 11 12 const MAPBOX_LANGUAGES = ['cs', 'hr', 'da', 'nl', 'en', 'et', 'fi', 'fr', 'de', 'el', 'hu', 'it', 'ja', 'lt', 'lv', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'es', 'sv', 'tr', 'uk']; 13 const GENERIC_LOCATION_TYPES = new Set(['yes', 'house', 'residential', 'building', 'address', 'apartments', 'detached', 'terrace']); 14 const scriptPromises = new Map(); 15 // A class being present in the weekly Maps JavaScript payload does not mean 16 // the key is entitled to call that API. Cache only a mode that completed a 17 // real autocomplete request successfully, scoped to the browser-visible key. 18 const verifiedGoogleApiTypes = new Map(); 19 20 function loadScript(url) { 21 if (!url) return Promise.reject(new Error('A script URL is required.')); 22 if (scriptPromises.has(url)) return scriptPromises.get(url); 23 24 const promise = new Promise((resolve, reject) => { 25 const existing = document.querySelector(`script[src="${url}"]`); 26 if (existing) { 27 if (existing.dataset.jrLoaded === 'true') return resolve(); 28 existing.addEventListener('load', resolve, { once: true }); 29 existing.addEventListener('error', reject, { once: true }); 30 return; 31 } 32 33 const script = document.createElement('script'); 34 script.src = url; 35 script.async = true; 36 script.addEventListener('load', () => { 37 script.dataset.jrLoaded = 'true'; 38 resolve(); 39 }, { once: true }); 40 script.addEventListener('error', reject, { once: true }); 41 document.head.appendChild(script); 42 }); 43 44 scriptPromises.set(url, promise); 45 return promise; 46 } 47 48 function uuid() { 49 return window.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`; 50 } 51 52 function parts(label) { 53 const values = String(label || '').split(',').map(value => value.trim()).filter(Boolean); 54 return { primary: values[0] || label || '', secondary: values.slice(1).join(', ') }; 55 } 56 57 function countries(value) { 58 return String(value || '') 59 .split(',') 60 .map(country => country.trim().toLowerCase()) 61 .filter(Boolean); 62 } 63 64 function suggestionLimit(value, fallback = 10) { 65 const limit = Number(value); 66 return Number.isInteger(limit) && limit > 0 ? Math.min(limit, 50) : fallback; 67 } 68 69 function photonType(properties) { 70 return properties.osm_value || properties.type || properties.osm_type || 'address'; 71 } 72 73 function formatPhotonSuggestion(properties) { 74 const name = properties.name || 'Unknown location'; 75 const county = properties.county; 76 const primary = county && !name.includes(county) ? `${name}, ${county}` : name; 77 const secondary = []; 78 const type = photonType(properties); 79 80 if (type && !GENERIC_LOCATION_TYPES.has(type)) secondary.push(type); 81 if (properties.state && !primary.includes(properties.state)) secondary.push(properties.state); 82 if (properties.country && !primary.includes(properties.country)) secondary.push(properties.country); 83 84 return { 85 primary, 86 secondary: secondary.join(', '), 87 label: [primary, ...secondary].join(', ') 88 }; 89 } 90 91 function componentsFromGoogle(items, modern) { 92 const result = {}; 93 (items || []).forEach(component => { 94 const long = modern ? component.longText : component.long_name; 95 const short = modern ? component.shortText : component.short_name; 96 if (component.types.includes('street_number')) result.streetNumber = long; 97 if (component.types.includes('route')) result.street = long; 98 if (component.types.includes('locality')) result.city = long; 99 if (!result.city && component.types.includes('postal_town')) result.city = long; 100 if (component.types.includes('administrative_area_level_1')) result.state = short || long; 101 if (component.types.includes('postal_code')) result.postalcode = long; 102 if (component.types.includes('country')) result.country = long; 103 }); 104 result.address = [result.streetNumber, result.street].filter(
104Boolean).join(' '); 105 return result; 106 } 107 108 class LocationSearchClient { 109 constructor(config = {}) { 110 this.config = config; 111 this.provider = config.geocodingService || 'nominatim'; 112 this.countryCodes = countries(config.countryRestriction); 113 this.limit = suggestionLimit(config.suggestionLimit); 114 this.mapboxSessionToken = null; 115 this.googleNewSessionToken = null; 116 this.googleLegacySessionToken = null; 117 this.googleModeKey = config.googleApiKey || '__loaded-google-api__'; 118 this.googleApiType = verifiedGoogleApiTypes.get(this.googleModeKey) || null; 119 this.googleAutocomplete = null; 120 this.googlePlaces = null; 121 } 122 123 get supportsSuggestions() { 124 // The public Nominatim usage policy forbids autocomplete requests. 125 return this.provider !== 'nominatim'; 126 } 127 128 async ensureReady() { 129 if (this.provider !== 'google') return; 130 131 // Legacy pages already load Google before constructing their provider. 132 // Do not require the modern coordinator in that case. 133 if (!window.google?.maps?.places) { 134 if (!window.jrMapsApi) await loadScript(this.config.coordinatorScript); 135 await window.jrMapsApi.loadGoogle(this.config.googleApiKey, { 136 libraries: ['places'], 137 locale: this.config.googleApiLocale || 'en', 138 apiUrl: this.config.googleApiUrl 139 }); 140 } 141 142 const places = window.google?.maps?.places; 143 if (places?.AutocompleteService) { 144 this.googleAutocomplete ||= new places.AutocompleteService(); 145 this.googlePlaces ||= new places.PlacesService(document.createElement('div')); 146 } 147 if (!places?.AutocompleteSuggestion?.fetchAutocompleteSuggestions && !this.googleAutocomplete) { 148 throw new Error('Google Places could not be loaded.'); 149 } 150 } 151 152 async suggest(query, options = {}) { 153 const value = String(query || '').trim(); 154 if (!value) return []; 155 if (!this.supportsSuggestions && !options.explicit) return []; 156 await this.ensureReady(); 157 const provider = this[`suggest_${this.provider}`]; 158 if (!provider) throw new Error(`Unsupported geocoder: ${this.provider}`); 159 return provider.call(this, value, options); 160 } 161 162 async resolve(suggestion) { 163 await this.ensureReady(); 164 const provider = this[`resolve_${this.provider}`]; 165 if (!provider) throw new Error(`Unsupported geocoder: ${this.provider}`); 166 const result = await provider.call(this, suggestion); 167 return result ? { ...suggestion, ...result, label: result.label || result.formatted_address || suggestion.label } : null; 168 } 169 170 async search(query) { 171 const suggestions = await this.suggest(query, { explicit: true }); 172 return suggestions[0] ? this.resolve(suggestions[0]) : null; 173 } 174 175 verifyGoogleApiType(type) { 176 this.googleApiType = type; 177 verifiedGoogleApiTypes.set(this.googleModeKey, type); 178 } 179 180 isGoogleEntitlementError(error) { 181 const code = String(error?.code || error?.status || '').toLowerCase(); 182 const message = `${code} ${String(error?.message || error || '')}`.toLowerCase(); 183 if (Number(error?.code) === 7) return true; 184 return message.includes('request_denied') 185 || message.includes('permission_denied') 186 || message.includes('api_not_activated') 187 || message.includes('apinotactivatedmaperror') 188 || message.includes('requests to this api') 189 || message.includes('blocked') 190 || message.includes('legacy api') 191 || message.includes('not enabled') 192 || message.includes('disabled') 193 || message.includes('has not been used') 194 || message.includes('not authorized') 195 || message.includes('unauthorized') 196 || message.includes('permission') 197 || message.includes('access not configured') 198 || message.includes('forbidden') 199 || message.includes('403') 200 || message.includes('rpcerror'); 201 } 202 203 async suggest_google(query) { 204 const places = google.maps.places; 205 const hasNew = Boolean(places?.AutocompleteSuggestion?.fetchAutocompleteSuggestions); 206 const hasLegacy = Boolean(this.googleAutocomplete); 207 const preferred = this.googleApiType; 208 209 if (preferred === 'legacy' && hasLegacy) { 210 try { 211 return await this.suggestGoogleLegacy(query); 212 } catch (error) { 213 if (!hasNew || !this.isGoogleEntitlementError(error)) throw error; 214 this.googleLegacySessionToken = null; 215 const suggestions = await this.suggestGoogleNew(query); 216 this.verifyGoogleApiType('new'); 217 return suggestions; 218 } 219 } 220 221 if (hasNew) { 222 try { 223 const suggestions = await this.suggestGoogleNew(query); 224 this.verifyGoogleApiType('new'); 225 return suggestions; 226 } catch (error) { 227 if (!hasLegacy || !this.isGoogleEntitlementError(error)) throw error; 228 this.googleNewSessionToken = null; 229 const suggestions = await this.suggestGoogleLegacy(query); 230 this.verifyGoogleApiType('legacy'); 231 return suggestions; 232 } 233 } 234 235 if (hasLegacy) { 236 const suggestions = await this.suggestGoogleLegacy(query); 237 this.verifyGoogleApiType('legacy'); 238 return suggestions; 239 } 240 241 throw new Error('No compatible Google Places autocomplete API is available.'); 242 } 243 244 async suggestGoogleNew(query) { 245 const places = google.maps.places; 246 this.googleNewSessionToken ||= new places.AutocompleteSessionToken(); 247 const request = { input: query, sessionToken: this.googleNewSessionToken };
248 if (this.countryCodes.length) request.includedRegionCodes = this.countryCodes; 249 const response = await places.AutocompleteSuggestion.fetchAutocompleteSuggestions(request); 250 return (response.suggestions || []).filter(item => item.placePrediction).map(item => { 251 const prediction = item.placePrediction; 252 return { 253 id: prediction.placeId, 254 ref: prediction.placeId, 255 data: prediction, 256 apiGeneration: 'new', 257 label: prediction.text?.text || '', 258 primary: prediction.structuredFormat?.mainText?.text || prediction.text?.text || '', 259 secondary: prediction.structuredFormat?.secondaryText?.text || '' 260 }; 261 }); 262 } 263 264 suggestGoogleLegacy(query) { 265 const places = google.maps.places; 266 this.googleLegacySessionToken ||= new places.AutocompleteSessionToken(); 267 return new Promise((resolve, reject) => { 268 const request = { input: query, sessionToken: this.googleLegacySessionToken }; 269 if (this.countryCodes.length) request.componentRestrictions = { country: this.countryCodes }; 270 this.googleAutocomplete.getPlacePredictions(request, (predictions, status) => { 271 if (status === places.PlacesServiceStatus.ZERO_RESULTS) return resolve([]); 272 if (status !== places.PlacesServiceStatus.OK || !predictions) { 273 return reject(new Error(`Google Places legacy autocomplete failed: ${status || 'UNKNOWN_ERROR'}`)); 274 } 275 resolve(predictions.map(prediction => ({ 276 id: prediction.place_id, 277 ref: prediction.place_id, 278 data: prediction, 279 googleSessionToken: this.googleLegacySessionToken, 280 apiGeneration: 'legacy', 281 label: prediction.description, 282 primary: prediction.structured_formatting?.main_text || prediction.description, 283 secondary: prediction.structured_formatting?.secondary_text || '' 284 }))); 285 }); 286 }); 287 } 288 289 async resolve_google(suggestion) { 290 const generation = suggestion.apiGeneration || this.googleApiType; 291 if (generation === 'new') { 292 if (suggestion.apiGeneration === 'new' && !suggestion.data?.toPlace) { 293 throw new Error('Google Places New suggestion is missing its native PlacePrediction.'); 294 } 295 const place = suggestion.data?.toPlace ? suggestion.data.toPlace() : new google.maps.places.Place({ id: suggestion.ref }); 296 try { 297 await place.fetchFields({ fields: ['formattedAddress', 'addressComponents', 'location', 'viewport'] }); 298 const viewport = place.viewport; 299 return { 300 formatted_address: place.formattedAddress, 301 lat: place.location.lat(), 302 lng: place.location.lng(), 303 bounds: viewport ? [viewport.getSouthWest
303().lng(), viewport.getSouthWest().lat(), viewport.getNorthEast().lng(), viewport.getNorthEast().lat()] : null, 304 components: componentsFromGoogle(place.addressComponents, true) 305 }; 306 } finally { 307 this.googleNewSessionToken = null; 308 } 309 } 310 311 if (generation !== 'legacy') throw new Error('Google suggestion API generation is unknown.'); 312 return new Promise((resolve, reject) => { 313 this.googlePlaces.getDetails({ 314 placeId: suggestion.ref, 315 sessionToken: suggestion.googleSessionToken || this.googleLegacySessionToken, 316 fields: ['formatted_address', 'address_components', 'geometry'] 317 }, (place, status) => { 318 this.googleLegacySessionToken = null; 319 if (status !== google.maps.places.PlacesServiceStatus.OK || !place) { 320 return reject(new Error(`Google Places legacy details failed: ${status || 'UNKNOWN_ERROR'}`)); 321 } 322 const viewport = place.geometry.viewport; 323 resolve({ 324 formatted_address: place.formatted_address, 325 lat: place.geometry.location.lat(), 326 lng: place.geometry.location.lng(), 327 bounds: viewport ? [viewport.getSouthWest
327().lng(), viewport.getSouthWest().lat(), viewport.getNorthEast().lng(), viewport.getNorthEast().lat()] : null, 328 components: componentsFromGoogle(place.address_components, false) 329 }); 330 }); 331 }); 332 } 333 334 async suggest_mapbox(query) { 335 this.mapboxSessionToken ||= uuid(); 336 const params = new URLSearchParams({ 337 q: query, 338 access_token: this.config.mapboxToken || '', 339 session_token: this.mapboxSessionToken, 340 limit: String(this.limit) 341 }); 342 if (this.countryCodes.length) params.set('country', this.countryCodes.join(',')); 343 if (MAPBOX_LANGUAGES.includes(this.config.geocodeLang)) params.set('language', this.config.geocodeLang); 344 const data = await fetch(`https://api.mapbox.com/search/searchbox/v1/suggest?${params}`).then(response => response.json()); 345 return (data.suggestions || []).map((item, index) => { 346 const place = item.context?.place?.name; 347 let primary = item.name || item.full_address; 348 if (place && !primary.includes(place)) primary = `${primary}, ${place}`; 349 return { 350 id: item.mapbox_id || `mapbox-${index}`, 351 ref: item.mapbox_id, 352 data: item, 353 label: item.full_address || item.name, 354 primary, 355 secondary: [item.poi_category?.[0] || (item.feature_type !== 'address' ? item.feature_type : ''), item.context?.region?.name, item.context?.country?.name].filter(Boolean).join(', ') 356 }; 357 }); 358 } 359 360 async resolve_mapbox(suggestion) { 361 const params = new URLSearchParams({ access_token: this.config.mapboxToken || '', session_token: this.mapboxSessionToken || '' }); 362 if (MAPBOX_LANGUAGES.includes(this.config.geocodeLang)) params.set('language', this.config.geocodeLang); 363 const data = await fetch(`https://api.mapbox.com/search/searchbox/v1/retrieve/${encodeURIComponent(suggestion.ref)}?${params}`).then(response => response.json()); 364 this.mapboxSessionToken = null; 365 const feature = data.features?.[0]; 366 if (!feature) return null; 367 const properties = feature.properties || {}; 368 return { 369 formatted_address: properties.full_address || properties.name, 370 lat: feature.geometry.coordinates[1], 371 lng: feature.geometry.coordinates[0], 372 bounds: feature.bbox || null, 373 components: { 374 city: properties.context?.place?.name, 375 state: properties.context?.region?.name, 376 postalcode: properties.context?.postcode?.name, 377 country: properties.context?.country?.name 378 } 379 }; 380 } 381 382 async suggest_nominatim(query) { 383 const url = new URL('https://nominatim.openstreetmap.org/search'); 384 url.searchParams.set('q', query); 385 url.searchParams.set('format', 'json'); 386 url.searchParams.set('limit', '1'); 387 url.searchParams.set('addressdetails', '1'); 388 if (this.countryCodes.length) url.searchParams.set('countrycodes', this.countryCodes.join(',')); 389 if (this.config.geocodeLang) url.searchParams.set('accept-language', this.config.geocodeLang); 390 const data = await fetch(url).then(response => response.json()); 391 return data.map(item => ({ id: item.place_id, ref: item, data: item, label: item.display_name, ...parts(item.display_name) })); 392 } 393 394 resolve_nominatim(suggestion) { 395 const item = suggestion.data; 396 const box = item.boundingbox?.map(Number); 397 return { 398 formatted_address: item.display_name, 399 lat: Number(item.lat), 400 lng: Number(item.lon), 401 bounds: box?.length === 4 ? [box[2], box[0], box[3], box[1]] : null, 402 components: { city: item.address?.city || item.address?.town || item.address?.village, state: item.address?.state, postalcode: item.address?.postcode, country: item.address?.country } 403 }; 404 } 405 406 async suggest_arcgis(query) { 407 const params = new URLSearchParams({ text: query, f: 'json', maxSuggestions: String(this.limit) }); 408 if (this.countryCodes.length) params.set('countryCode', this.countryCodes.join(',')); 409 const data = await fetch(`https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/suggest?${params}`).then(response => response.json()); 410 return (data.suggestions || []).map(item => ({ id: item.magicKey, ref: item.magicKey, label: item.text, ...parts(item.text) })); 411 } 412 413 async resolve_arcgis(suggestion) { 414 const params = new URLSearchParams({ magicKey: suggestion.ref, f: 'json', outFields: 'City,Region,Postal,Country' });
415 if (this.config.geocodeLang) params.set('langCode', this.config.geocodeLang); 416 const data = await fetch(`https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates?${params}`).then(response => response.json()); 417 const item = data.candidates?.[0]; 418 if (!item) return null; 419 return { formatted_address: item.address, lat: item.location.y, lng: item.location.x, bounds: item.extent ? [item.extent.xmin, item.extent.ymin, item.extent.xmax, item.extent.ymax] : null, components: { city: item.attributes.City, state: item.attributes.Region, postalcode: item.attributes.Postal, country: item.attributes.Country } }; 420 } 421 422 async suggest_photon(query) { 423 const url = new URL('https://photon.komoot.io/api/'); 424 url.searchParams.set('q', query); 425 // Photon has no country parameter. Fetch a wider candidate set when a 426 // country restriction is configured, then apply the setting locally. 427 url.searchParams.set('limit', String(this.countryCodes.length ? Math.min(Math.max(this.limit * 5, 10), 50) : this.limit)); 428 if (this.config.geocodeLang) url.searchParams.set('lang', this.config.geocodeLang); 429 const data = await fetch(url).then(response => response.json()); 430 return (data.features || []).filter(feature => { 431 if (!this.countryCodes.length) return true; 432 return this.countryCodes.includes(String(feature.properties?.countrycode || '').toLowerCase()); 433 }).slice(0, this.limit).map((feature, index) => { 434 const properties = feature.properties || {}; 435 const formatted = formatPhotonSuggestion(properties); 436 // Photon can return the same OSM object more than once with a 437 // different key/value (Golden Gate Bridge is a real example). 438 // Include every discriminator so Alpine x-for keys stay unique. 439 const id = ['photon', properties.osm_type, properties.osm_id, properties.osm_key, properties.osm_value, index] 440 .filter(value => value !== undefined && value !== null && value !== '') 441 .join('-'); 442 return { id, ref: feature, data: feature, ...formatted }; 443 }); 444 } 445 446 resolve_photon(suggestion) { 447 const feature = suggestion.data; 448 const properties = feature.properties || {}; 449 return { 450 formatted_address: suggestion.label, 451 lat: feature.geometry.coordinates[1], 452 lng: feature.geometry.coordinates[0], 453 bounds: feature.bbox || null, 454 type: photonType(properties), 455 components: { 456 address: [properties.housenumber, properties.street].filter(Boolean).join(' '), 457 city: properties.city || properties.town || properties.village, 458 county: properties.county, 459 state: properties.state, 460 postalcode: properties.postcode, 461 country: properties.country, 462 countrycode: properties.countrycode 463 } 464 }; 465 } 466 } 467 468 window.jrMapsLocationSearch = { 469 create(config) { return new LocationSearchClient(config); } 470 }; 471})(window);
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.