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 /** 109 * The provider did not answer, as distinct from answering with nothing. 110 * 111 * These are different states and must never share a message. A blocked, 112 * expired or over-quota token makes every request fail; reporting that as 113 * "no locations found" tells every visitor there is nothing near them and 114 * tells the owner nothing at all, while looking like an ordinary empty 115 * result. Callers show unavailability for this, and the honest empty state 116 * only for an answer that really was empty. 117 */ 118 class LocationSearchUnavailable extends Error { 119 constructor(reason, status = 0, cause = null) { 120 super(`Location search provider did not answer (${reason}${status ? ' ' + status : ''})`); 121 this.name = 'LocationSearchUnavailable'; 122 this.reason = reason; // 'http' | 'network' | 'malformed' 123 this.status = status; // HTTP status, 0 when there was no response 124 this.cause = cause; 125 } 126 } 127 128 /** 129 * Read a provider's JSON, refusing to turn a failure into an empty answer. 130 * 131 * Every branch here used to collapse into `{}`: `fetch(...).then(r => r.json())` 132 * never consulted `response.ok`, so a 403 body like {"message":"Forbidden"} 133 * parsed cleanly, the expected key was absent, and the caller received an 134 * empty list it could not tell from a real one. 135 */ 136 async function fetchJson(url, options) { 137 let response; 138 139 try { 140 response = await fetch(url, options); 141 } catch (cause) { 142 // Network down, DNS failure, CORS rejection, aborted connection. 143 throw new LocationSearchUnavailable('network', 0, cause); 144 } 145 146 if (!response.ok) { 147 throw new LocationSearchUnavailable('http', response.status); 148 } 149 150 try { 151 return await response.json(); 152 } catch (cause) { 153 // A 200 carrying HTML - a captive portal or an error page - is still 154 // the provider failing to answer, not an empty result. 155 throw new LocationSearchUnavailable('malformed', response.status, cause); 156 } 157 } 158 159 class LocationSearchClient { 160 constructor(config = {}) { 161 this.config = config; 162 this.provider = config.geocodingService || 'nominatim'; 163 this.countryCodes = countries(config.countryRestriction); 164 this.limit = suggestionLimit(config.suggestionLimit); 165 this.mapboxSessionToken = null; 166 this.googleNewSessionToken = null; 167 this.googleLegacySessionToken = null; 168 this.googleModeKey = config.googleApiKey || '__loaded-google-api__'; 169 this.googleApiType = verifiedGoogleApiTypes.get(this.googleModeKey) || null; 170 this.googleAutocomplete = null; 171 this.googlePlaces = null; 172 } 173 174 get supportsSuggestions() { 175 // The public Nominatim usage policy forbids autocomplete requests. 176 return this.provider !== 'nominatim'; 177 } 178 179 async ensureReady() { 180 if (this.provider !== 'google') return; 181 182 // Legacy pages already load Google before constructing their provider. 183 // Do not require the modern coordinator in that case. 184 if (!window.google?.maps?.places) { 185 if (!window.jrMapsApi) await loadScript(this.config.coordinatorScript); 186 await window.jrMapsApi.loadGoogle(this.config.googleApiKey, { 187 libraries: ['places'], 188 locale: this.config.googleApiLocale || 'en', 189 apiUrl: this.config.googleApiUrl 190 }); 191 } 192 193 // The legacy services are built in ensureLegacyGoogleServices() at the 194 // moment a legacy call is made. Constructing them here made Google log 195 // its AutocompleteService deprecation notice on every page with a map, 196 // including the pages that go on to use the modern API exclusively. 197 const places = window.google?.maps?.places; 198 if (!places?.AutocompleteSuggestion?.fetchAutocompleteSuggestions && !places?.AutocompleteService) { 199 throw new Error('Google Places could not be loaded.'); 200 } 201 } 202 203 /** 204 * Build the deprecated autocomplete/places services on first legacy use. 205 * Returns false when the payload cannot serve them. 206 */ 207 ensureLegacyGoogleServices() { 208 const places = window.google?.maps?.places; 209 if (!places?.AutocompleteService) return false;
210 211 this.googleAutocomplete ||= new places.AutocompleteService(); 212 this.googlePlaces ||= new places.PlacesService(document.createElement('div')); 213 214 return true; 215 } 216 217 async suggest(query, options = {}) { 218 const value = String(query || '').trim(); 219 if (!value) return []; 220 if (!this.supportsSuggestions && !options.explicit) return []; 221 await this.ensureReady(); 222 const provider = this[`suggest_${this.provider}`]; 223 if (!provider) throw new Error(`Unsupported geocoder: ${this.provider}`); 224 return provider.call(this, value, options); 225 } 226 227 async resolve(suggestion) { 228 await this.ensureReady(); 229 const provider = this[`resolve_${this.provider}`]; 230 if (!provider) throw new Error(`Unsupported geocoder: ${this.provider}`); 231 const result = await provider.call(this, suggestion); 232 return result ? { ...suggestion, ...result, label: result.label || result.formatted_address || suggestion.label } : null; 233 } 234 235 async search(query) { 236 const suggestions = await this.suggest(query, { explicit: true }); 237 return suggestions[0] ? this.resolve(suggestions[0]) : null; 238 } 239 240 verifyGoogleApiType(type) { 241 this.googleApiType = type; 242 verifiedGoogleApiTypes.set(this.googleModeKey, type); 243 } 244 245 isGoogleEntitlementError(error) { 246 const code = String(error?.code || error?.status || '').toLowerCase(); 247 const message = `${code} ${String(error?.message || error || '')}`.toLowerCase(); 248 if (Number(error?.code) === 7) return true; 249 return message.includes('request_denied') 250 || message.includes('permission_denied') 251 || message.includes('api_not_activated') 252 || message.includes('apinotactivatedmaperror') 253 || message.includes('requests to this api') 254 || message.includes('blocked') 255 || message.includes('legacy api') 256 || message.includes('not enabled') 257 || message.includes('disabled') 258 || message.includes('has not been used') 259 || message.includes('not authorized') 260 || message.includes('unauthorized') 261 || message.includes('permission') 262 || message.includes('access not configured') 263 || message.includes('forbidden') 264 || message.includes('403') 265 || message.includes('rpcerror'); 266 } 267 268 async suggest_google(query) { 269 const places = google.maps.places; 270 const hasNew = Boolean(places?.AutocompleteSuggestion?.fetchAutocompleteSuggestions); 271 const hasLegacy = Boolean(places?.AutocompleteService); 272 const preferred = this.googleApiType; 273 274 if (preferred === 'legacy' && hasLegacy) { 275 try { 276 return await this.suggestGoogleLegacy(query); 277 } catch (error) { 278 if (!hasNew || !this.isGoogleEntitlementError(error)) throw error; 279 this.googleLegacySessionToken = null; 280 const suggestions = await this.suggestGoogleNew(query); 281 this.verifyGoogleApiType('new'); 282 return suggestions; 283 } 284 } 285 286 if (hasNew) { 287 try { 288 const suggestions = await this.suggestGoogleNew(query); 289 this.verifyGoogleApiType('new'); 290 return suggestions; 291 } catch (error) { 292 if (!hasLegacy || !this.isGoogleEntitlementError(error)) throw error; 293 this.googleNewSessionToken = null; 294 const suggestions = await this.suggestGoogleLegacy(query); 295 this.verifyGoogleApiType('legacy'); 296 return suggestions; 297 } 298 } 299 300 if (hasLegacy) { 301 const suggestions = await this.suggestGoogleLegacy(query); 302 this.verifyGoogleApiType('legacy'); 303 return suggestions; 304 } 305 306 throw new Error('No compatible Google Places autocomplete API is available.'); 307 } 308 309 async suggestGoogleNew(query) { 310 const places = google.maps.places; 311 this.googleNewSessionToken ||= new places.AutocompleteSessionToken(); 312 const request = { input: query, sessionToken: this.googleNewSessionToken };
313 if (this.countryCodes.length) request.includedRegionCodes = this.countryCodes; 314 const response = await places.AutocompleteSuggestion.fetchAutocompleteSuggestions(request); 315 return (response.suggestions || []).filter(item => item.placePrediction).map(item => { 316 const prediction = item.placePrediction; 317 return { 318 id: prediction.placeId, 319 ref: prediction.placeId, 320 data: prediction, 321 apiGeneration: 'new', 322 label: prediction.text?.text || '', 323 primary: prediction.structuredFormat?.mainText?.text || prediction.text?.text || '', 324 secondary: prediction.structuredFormat?.secondaryText?.text || '' 325 }; 326 }); 327 } 328 329 suggestGoogleLegacy(query) { 330 const places = google.maps.places; 331 if (!this.ensureLegacyGoogleServices()) { 332 return Promise.reject(new Error('Google Places legacy autocomplete is not available.')); 333 } 334 this.googleLegacySessionToken ||= new places.AutocompleteSessionToken(); 335 return new Promise((resolve, reject) => { 336 const request = { input: query, sessionToken: this.googleLegacySessionToken }; 337 if (this.countryCodes.length) request.componentRestrictions = { country: this.countryCodes }; 338 this.googleAutocomplete.getPlacePredictions(request, (predictions, status) => { 339 if (status === places.PlacesServiceStatus.ZERO_RESULTS) return resolve([]); 340 if (status !== places.PlacesServiceStatus.OK || !predictions) { 341 return reject(new Error(`Google Places legacy autocomplete failed: ${status || 'UNKNOWN_ERROR'}`)); 342 } 343 resolve(predictions.map(prediction => ({ 344 id: prediction.place_id, 345 ref: prediction.place_id, 346 data: prediction, 347 googleSessionToken: this.googleLegacySessionToken, 348 apiGeneration: 'legacy', 349 label: prediction.description, 350 primary: prediction.structured_formatting?.main_text || prediction.description, 351 secondary: prediction.structured_formatting?.secondary_text || '' 352 }))); 353 }); 354 }); 355 } 356 357 async resolve_google(suggestion) { 358 const generation = suggestion.apiGeneration || this.googleApiType; 359 if (generation === 'new') { 360 if (suggestion.apiGeneration === 'new' && !suggestion.data?.toPlace) { 361 throw new Error('Google Places New suggestion is missing its native PlacePrediction.'); 362 } 363 const place = suggestion.data?.toPlace ? suggestion.data.toPlace() : new google.maps.places.Place({ id: suggestion.ref }); 364 try { 365 await place.fetchFields({ fields: ['formattedAddress', 'addressComponents', 'location', 'viewport'] }); 366 const viewport = place.viewport; 367 return { 368 formatted_address: place.formattedAddress, 369 lat: place.location.lat(), 370 lng: place.location.lng(), 371 bounds: viewport ? [viewport.getSouthWest
371().lng(), viewport.getSouthWest().lat(), viewport.getNorthEast().lng(), viewport.getNorthEast().lat()] : null, 372 components: componentsFromGoogle(place.addressComponents, true) 373 }; 374 } finally { 375 this.googleNewSessionToken = null; 376 } 377 } 378 379 if (generation !== 'legacy') throw new Error('Google suggestion API generation is unknown.'); 380 if (!this.ensureLegacyGoogleServices()) { 381 throw new Error('Google Places legacy details are not available.'); 382 } 383 return new Promise((resolve, reject) => { 384 this.googlePlaces.getDetails({ 385 placeId: suggestion.ref, 386 sessionToken: suggestion.googleSessionToken || this.googleLegacySessionToken, 387 fields: ['formatted_address', 'address_components', 'geometry'] 388 }, (place, status) => { 389 this.googleLegacySessionToken = null; 390 if (status !== google.maps.places.PlacesServiceStatus.OK || !place) { 391 return reject(new Error(`Google Places legacy details failed: ${status || 'UNKNOWN_ERROR'}`)); 392 } 393 const viewport = place.geometry.viewport; 394 resolve({ 395 formatted_address: place.formatted_address, 396 lat: place.geometry.location.lat(), 397 lng: place.geometry.location.lng(), 398 bounds: viewport ? [viewport.getSouthWest
398().lng(), viewport.getSouthWest().lat(), viewport.getNorthEast().lng(), viewport.getNorthEast().lat()] : null, 399 components: componentsFromGoogle(place.address_components, false) 400 }); 401 }); 402 }); 403 } 404 405 async suggest_mapbox(query) { 406 this.mapboxSessionToken ||= uuid(); 407 const params = new URLSearchParams({ 408 q: query, 409 access_token: this.config.mapboxToken || '', 410 session_token: this.mapboxSessionToken, 411 limit: String(this.limit) 412 }); 413 if (this.countryCodes.length) params.set('country', this.countryCodes.join(',')); 414 if (MAPBOX_LANGUAGES.includes(this.config.geocodeLang)) params.set('language', this.config.geocodeLang); 415 const data = await fetchJson(`https://api.mapbox.com/search/searchbox/v1/suggest?${params}`); 416 return (data.suggestions || []).map((item, index) => { 417 const place = item.context?.place?.name; 418 let primary = item.name || item.full_address; 419 if (place && !primary.includes(place)) primary = `${primary}, ${place}`; 420 return { 421 id: item.mapbox_id || `mapbox-${index}`, 422 ref: item.mapbox_id, 423 data: item, 424 label: item.full_address || item.name, 425 primary, 426 secondary: [item.poi_category?.[0] || (item.feature_type !== 'address' ? item.feature_type : ''), item.context?.region?.name, item.context?.country?.name].filter(Boolean).join(', ') 427 }; 428 }); 429 } 430 431 async resolve_mapbox(suggestion) { 432 const params = new URLSearchParams({ access_token: this.config.mapboxToken || '', session_token: this.mapboxSessionToken || '' }); 433 if (MAPBOX_LANGUAGES.includes(this.config.geocodeLang)) params.set('language', this.config.geocodeLang); 434 const data = await fetchJson(`https://api.mapbox.com/search/searchbox/v1/retrieve/${encodeURIComponent(suggestion.ref)}?${params}`); 435 this.mapboxSessionToken = null; 436 const feature = data.features?.[0]; 437 if (!feature) return null; 438 const properties = feature.properties || {}; 439 return { 440 formatted_address: properties.full_address || properties.name, 441 lat: feature.geometry.coordinates[1], 442 lng: feature.geometry.coordinates[0], 443 bounds: feature.bbox || null, 444 components: { 445 city: properties.context?.place?.name, 446 state: properties.context?.region?.name, 447 postalcode: properties.context?.postcode?.name, 448 country: properties.context?.country?.name 449 } 450 }; 451 } 452 453 async suggest_nominatim(query) { 454 const url = new URL('https://nominatim.openstreetmap.org/search'); 455 url.searchParams.set('q', query); 456 url.searchParams.set('format', 'json'); 457 url.searchParams.set('limit', '1'); 458 url.searchParams.set('addressdetails', '1'); 459 if (this.countryCodes.length) url.searchParams.set('countrycodes', this.countryCodes.join(',')); 460 if (this.config.geocodeLang) url.searchParams.set('accept-language', this.config.geocodeLang); 461 const data = await fetchJson(url); 462 return data.map(item => ({ id: item.place_id, ref: item, data: item, label: item.display_name, ...parts(item.display_name) })); 463 } 464 465 resolve_nominatim(suggestion) { 466 const item = suggestion.data; 467 const box = item.boundingbox?.map(Number); 468 return { 469 formatted_address: item.display_name, 470 lat: Number(item.lat), 471 lng: Number(item.lon), 472 bounds: box?.length === 4 ? [box[2], box[0], box[3], box[1]] : null, 473 components: { city: item.address?.city || item.address?.town || item.address?.village, state: item.address?.state, postalcode: item.address?.postcode, country: item.address?.country } 474 }; 475 } 476 477 async suggest_arcgis(query) { 478 const params = new URLSearchParams({ text: query, f: 'json', maxSuggestions: String(this.limit) }); 479 if (this.countryCodes.length) params.set('countryCode', this.countryCodes.join(',')); 480 const data = await fetchJson(`https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/suggest?${params}`); 481 return (data.suggestions || []).map(item => ({ id: item.magicKey, ref: item.magicKey, label: item.text, ...parts(item.text) })); 482 } 483 484 async resolve_arcgis(suggestion) { 485 const params = new URLSearchParams({ magicKey: suggestion.ref, f: 'json', outFields: 'City,Region,Postal,Country' });
486 if (this.config.geocodeLang) params.set('langCode', this.config.geocodeLang); 487 const data = await fetchJson(`https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates?${params}`); 488 const item = data.candidates?.[0]; 489 if (!item) return null; 490 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 } }; 491 } 492 493 async suggest_photon(query) { 494 const url = new URL('https://photon.komoot.io/api/'); 495 url.searchParams.set('q', query); 496 // Photon has no country parameter. Fetch a wider candidate set when a 497 // country restriction is configured, then apply the setting locally. 498 url.searchParams.set('limit', String(this.countryCodes.length ? Math.min(Math.max(this.limit * 5, 10), 50) : this.limit)); 499 if (this.config.geocodeLang) url.searchParams.set('lang', this.config.geocodeLang); 500 const data = await fetchJson(url); 501 return (data.features || []).filter(feature => { 502 if (!this.countryCodes.length) return true; 503 return this.countryCodes.includes(String(feature.properties?.countrycode || '').toLowerCase()); 504 }).slice(0, this.limit).map((feature, index) => { 505 const properties = feature.properties || {}; 506 const formatted = formatPhotonSuggestion(properties); 507 // Photon can return the same OSM object more than once with a 508 // different key/value (Golden Gate Bridge is a real example). 509 // Include every discriminator so Alpine x-for keys stay unique. 510 const id = ['photon', properties.osm_type, properties.osm_id, properties.osm_key, properties.osm_value, index] 511 .filter(value => value !== undefined && value !== null && value !== '') 512 .join('-'); 513 return { id, ref: feature, data: feature, ...formatted }; 514 }); 515 } 516 517 resolve_photon(suggestion) { 518 const feature = suggestion.data; 519 const properties = feature.properties || {}; 520 return { 521 formatted_address: suggestion.label, 522 lat: feature.geometry.coordinates[1], 523 lng: feature.geometry.coordinates[0], 524 bounds: feature.bbox || null, 525 type: photonType(properties), 526 components: { 527 address: [properties.housenumber, properties.street].filter(Boolean).join(' '), 528 city: properties.city || properties.town || properties.village, 529 county: properties.county, 530 state: properties.state, 531 postalcode: properties.postcode, 532 country: properties.country, 533 countrycode: properties.countrycode 534 } 535 }; 536 } 537 } 538 539 /** One report per distinct failure per page, not one per keystroke. */ 540 const reportedFailures = new Set(); 541 542 /** 543 * Tell the site owner their geocoder is failing. 544 * 545 * The visitor already sees an honest message; this is the other half, and 546 * the half that was missing entirely. A referrer-restricted or expired token 547 * fails in the BROWSER, so without this the server never learns of it and 548 * the owner sees a feature that appears to work. 549 * 550 * Best effort by design: reporting must never break, block or slow the 551 * search it is reporting on, so every failure here is swallowed. 552 */ 553 function reportUnavailable(error, provider) { 554 if (error?.name !== 'LocationSearchUnavailable') return; 555 556 const url = window.jrMapsAutocomplete?.reportUrl; 557 if (!url) return; 558 559 const reason = error.reason || 'network'; 560 const status = Number(error.status) || 0;
561 const key = `${provider}|${reason}|${status}`; 562 563 if (reportedFailures.has(key)) return; 564 reportedFailures.add(key); 565 566 const body = new URLSearchParams({ 567 provider: String(provider || ''), 568 reason: String(reason), 569 status: String(status) 570 }); 571 572 try { 573 if (navigator.sendBeacon && navigator.sendBeacon(url, body)) return; 574 575 fetch(url, { 576 method: 'POST', 577 body, 578 keepalive: true, 579 headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' } 580 }).catch(() => {}); 581 } catch (ignored) { 582 // Never let telemetry surface as a visitor-facing failure. 583 } 584 } 585 586 window.jrMapsLocationSearch = { 587 create(config) { return new LocationSearchClient(config); }, 588 report: reportUnavailable, 589 // Published so a consumer can tell "did not answer" from "answered with 590 // nothing" without matching on message text. 591 Unavailable: LocationSearchUnavailable, 592 isUnavailable(error) { return error instanceof LocationSearchUnavailable; } 593 }; 594})(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.