PageSourceSearch

https://www.worthingtonchamber.org/assets/controllers/market_map_controller-ce858f34.js

js worthingtonchamber.org collected 2026-10-04 08:39:17 UTC 44,815 bytes, 1,240 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import { loadGoogleMaps } from "lib/google_maps_loader"
3
4const CONTIGUOUS_US_BOUNDS = {
5  north: 50,
6  south: 24,
7  east: -66,
8  west: -125,
9}
10const NATIONAL_SEARCH_BOUNDS = {
11  north: 72,
12  south: 18,
13  east: -65,
14  west: -170,
15}
16const SEARCH_DELAY_MS = 250
17const SEARCH_SUGGESTION_DELAY_MS = 140
18const SEARCH_MINIMUM_LENGTH = 3
19const VIEWPORT_DELAY_MS = 120
20const METERS_PER_MILE = 1609.344
21const MAINLAND_ZOOM_BOOST = 0.15
22const NARROW_MAINLAND_ZOOM_BOOST = 0.2
23const NARROW_MAP_WIDTH = 640
24const MAINLAND_MAX_ZOOM = 4.942
25const MAINLAND_WIDE_CENTER = { lat: 39.88, lng: -96.315 }
26const STATE_FIT_MAX_ZOOM = 9
27const STATE_FIT_PADDING = { top: 104, right: 48, bottom: 64, left: 48 }
28const STATE_LABEL_MIN_ZOOM = 6
29const MUTED_MAP_STYLES = [
30  { elementType: "geometry", stylers: [{ color: "#f4f4f3" }] },
31  { elementType: "labels.icon", stylers: [{ visibility: "off" }] },
32  { elementType: "labels.text.fill", stylers: [{ color: "#52525b" }] },
33  { elementType: "labels.text.stroke", stylers: [{ color: "#fafafa" }] },
34  { featureType: "administrative.country", elementType: "geometry.stroke", stylers: [{ color: "#a1a1aa" }, { weight: 1.2 }] },
35  { featureType: "administrative.country", elementType: "labels.text.fill", stylers: [{ color: "#3f3f46" }] },
36  { featureType: "administrative.province", elementType: "geometry.stroke", stylers: [{ color: "#aeb2b8" }, { weight: 1.1 }] },
37  { featureType: "administrative.province", elementType: "labels.text.fill", stylers: [{ color: "#71717a" }] },
38  { featureType: "poi", elementType: "geometry", stylers: [{ color: "#e7e7e5" }] },
39  { featureType: "poi", elementType: "labels", stylers: [{ visibility: "off" }] },
40  { featureType: "road", elementType: "geometry", stylers: [{ color: "#ffffff" }] },
41  { featureType: "road", elementType: "geometry.stroke", stylers: [{ color: "#e4e4e1" }] },
42  { featureType: "road.highway", elementType: "geometry", stylers: [{ color: "#dededb" }] },
43  { featureType: "road.highway", elementType: "geometry.stroke", stylers: [{ color: "#c7c7c3" }] },
44  { featureType: "road.highway", elementType: "labels.text.fill", stylers: [{ color: "#52525b" }] },
45  { featureType: "road.arterial", elementType: "labels.text.fill", stylers: [{ color: "#71717a" }] },
46  { featureType: "road.local", elementType: "labels.text.fill", stylers: [{ color: "#a1a1aa" }] },
47  { featureType: "transit", stylers: [{ visibility: "off" }] },
48  { featureType: "water", elementType: "geometry", stylers: [{ color: "#d9dcdd" }] },
49  { featureType: "water", elementType: "labels.text.fill", stylers: [{ color: "#71717a" }] },
50  { featureType: "water", elementType: "labels.text.stroke", stylers: [{ color: "#d9dcdd" }] },
51]
52const OVERVIEW_MAP_STYLES = [
53  ...MUTED_MAP_STYLES,
54  { featureType: "administrative.province", elementType: "labels", stylers: [{ visibility: "off" }] },
55]
56
57export default class extends Controller {
58  static targets = [
59    "map",
60    "mapStatus",
61    "resultStatus",
62    "kind",
63    "region",
64    "regionFormInput",
65    "search",
66    "searchResults",
67    "summaryKnown",
68    "summaryProspects",
69    "summaryCustomers",
70    "summaryCustomerTotal",
71    "summaryCoverage",
72    "detailIcon",
73    "detailName",
74    "detailLocation",
75    "detailBadge",
76    "detailMeta",
77    "detailRevenueRow",
78    "detailRevenue",
79    "detailWebsiteRow",
80    "detailWebsite",
81    "detailWebsiteSource",
82    "detailPhoneRow",
83    "detailPhone",
84    "detailExpensesRow",
85    "detailExpenses",
86    "detailOperatingResultRow",
87    "detailOperatingResultLabel",
88    "detailOperatingResult",
89    "detailEmployeesRow",
90    "detailEmployees",
91    "detailAssetsRow",
92    "detailAssets",
93    "detailNetAssetsRow",
94    "detailNetAssets",
95    "detailMembershipDuesRow",
96    "detailMembershipDues",
97    "detailProgramRevenueRow",
98    "detailProgramRevenue",
99    "detailExecutiveRow",
100    "detailExecutive",
101    "detailFilingRow",
102    "detailFiling",
103    "detailMissionRow",
104    "detailMission",
105  ]
106
107  static values = {
108    apiKey: String,
109    pointsUrl: String,
110    searchUrl: String,
111    originLatitude: Number,
112    originLongitude: Number,
113    originName: String,
114    radiusMiles: Number,
115    minimumRevenue: String,
116    includeUnknownRevenue: Boolean,
117  }
118
119  async connect() {
120    this.markers = new Map()
121    this.abortController = null
122    this.searchAbortController = null
123    this.fetchSequence = 0
124    this.searchSequence = 0
125    this.searchItems = []
126    this.activeSearchIndex = -1
127    this.pendingStateFit = null
128    this.lastRenderedStateKey = null
129    this.hideMarkerTooltipOnViewportChange = () => this.hideMarkerTooltip()
130    this.handleDocumentClick = (event) => {
131      if (event.target === this.searchTarget || this.searchResultsTarget.contains(event.target)) return
132
133      this.closeSearchResults()
134    }
135    document.addEventListener("click", this.handleDocumentClick)
136
137    try {
138      await loadGoogleMaps(this.apiKeyValue, ["maps"])
139      if (!this.element.isConnected) return
140
141      this.buildMap()
142      this.hideMapStatus()
143    } catch (_error) {
144      this.showMapError(this.mapUnavailableMessage())
145    }
146  }
147
148  disconnect() {
149    window.clearTimeout(this.searchTimer)
150    window.clearTimeout(this.searchSuggestionTimer)
151    window.clearTimeout(this.viewportTimer)
152    this.abortController?.abort()
153    this.abortController = null
154    this.searchAbortController?.abort()
155    this.searchAbortController = null
156    document.removeEventListener("click", this.handleDocumentClick)
157    this.idleListener?.remove()
158    this.idleListener = null
159    this.mapStyleListener?.remove()
160    this.mapStyleListener = null
161    this.mainlandFitListener?.remove()
162    this.mainlandFitListener = null
163    this.stateFitListener?.remove()
164    this.stateFitListener = null
165    this.hideMarkerTooltip()
166    this.clearMarkers()
167    this.selectedLocationMarker?.setMap(null)
168    this.selectedLocationMarker = null
169    this.originMarker?.setMap(null)
170    this.radiusCircle?.setMap(null)
171    this.map = null
172  }
173
174  filtersChanged() {
175    if (this.selectedRegion()) {
176      this.requestStateFit()
177    } else {
178      this.refreshPoints()
179    }
180    this.scheduleSearchSuggestions()
181  }
182
183  regionChanged() {
184    this.syncRegionFormInput()
185    this.scheduleSearchSuggestions()
186    this.selectedLocationMarker?.setMap(null)
187    this.clearMarkers()
188    if (!this.selectedRegion()) this.restoreMarketSummary()
189    if (!this.map) return
190
191    if (this.selectedRegion()) {
192      this.requestStateFit()
193    } else {
194      this.abortController?.abort()
195      this.abortController = null
196      this.fetchSequence += 1
197      this.fitMainlandView()
198    }
199  }
200
201  searchChanged() {
202    this.scheduleSearchSuggestions()
203    window.clearTimeout(this.searchTimer)
204    this.searchTimer = window.setTimeout(() => {
205      if (this.selectedRegion()) {
206        this.requestStateFit()
207      } else if (this.map && this.searchTarget.value.trim() !== "") {
208        this.cancelMainlandFitAdjustment()
209        this.map.fitBounds(NATIONAL_SEARCH_BOUNDS, 36)
210        window.clearTimeout(this.viewportTimer)
211        this.viewportTimer = window.setTimeout(() => this.refreshPoints(), VIEWPORT_DELAY_MS * 2)
212      } else {
213        this.refreshPoints()
214      }
215    }, SEARCH_DELAY_MS)
216  }
217
218  searchKeydown(event) {
219    if (event.key === "Escape") {
220      this.closeSearchResults()
221      return
222    }
223    if (this.searchResultsTarget.classList.contains("hidden") || this.searchItems.length === 0) return
224
225    if (event.key === "ArrowDown") {
226      event.preventDefault()
227      this.setActiveSearchIndex((this.activeSearchIndex + 1) % this.searchItems.length)
228    } else if (event.key === "ArrowUp") {
229      event.preventDefault()
230      const nextIndex = this.activeSearchIndex <= 0 ? this.searchItems.length - 1 : this.activeSearchIndex - 1
231      this.setActiveSearchIndex(nextIndex)
232    } else if (event.key === "Enter") {
233      event.preventDefault()
234      this.activateSearchItem(this.activeSearchIndex >= 0 ? this.activeSearchIndex : 0)
235    }
236  }
237
238  searchFocused() {
239    if (this.searchItems.length > 0) this.openSearchResults()
240  }
241
242  selectSearchResult(event) {
243    const option = event.target.closest("[data-search-index]")
244    if (!option) return
245
246    const index = Number.parseInt(option.dataset.searchIndex || "", 10)
247    if (Number.isInteger(index)) this.activateSearchItem(index)
248  }
249
250  scheduleSearchSuggestions() {
251    window.clearTimeout(this.searchSuggestionTimer)
252    this.searchAbortController?.abort()
253    this.searchAbortController = null
254    this.closeSearchResults({ clear: true })
255
256    const query = this.searchTarget.value.trim()
257    if (query.length < SEARCH_MINIMUM_LENGTH || !this.hasSearchUrlValue) return
258
259    this.searchSuggestionTimer = window.setTimeout(
260      () => this.fetchSearchSuggestions(query),
261      SEARCH_SUGGESTION_DELAY_MS
262    )
263  }
264
265  async fetchSearchSuggestions(query) {
266    const url = new URL(this.searchUrlValue, window.location.origin)
267    url.searchParams.set("q", query)
268    url.searchParams.set("kind", this.kindTarget.value)
269    url.searchParams.set("minimum_revenue", this.minimumRevenueValue)
270    url.searchParams.set("include_unknown_revenue", this.includeUnknownRevenueValue ? "1" : "0")
271    if (this.selectedRegion()) url.searchParams.set("region", this.selectedRegion())
272
273    this.searchAbortController = new AbortController()
274    const sequence = ++this.searchSequence
275
276    try {
277      const response = await fetch(url.toString(), {
278        headers: { Accept: "application/json" },
279        credentials: "same-origin",
280        signal: this.searchAbortController.signal,
281      })
282      if (!response.ok) throw new Error(`Market search failed with ${response.status}`)
283
284      const payload = await response.json()
285      if (
286        sequence !== this.searchSequence ||
287        !this.element.isConnected ||
288        this.searchTarget.value.trim() !== query
289      ) return
290
291      this.renderSearchSuggestions(Array.isArray(payload.items) ? payload.items : [])
292    } catch (error) {
293      if (error.name === "AbortError" || sequence !== this.searchSequence) return
294
295      this.closeSearchResults({ clear: true })
296    }
297  }
298
299  renderSearchSuggestions(items) {
300    this.searchItems = items
301    this.activeSearchIndex = -1
302    this.searchTarget.removeAttribute("aria-activedescendant")
303
304    if (items.length === 0) {
305      const empty = document.createElement("p")
306      empty.className = "px-3 py-2.5 text-xs text-zinc-500"
307      empty.textContent = "No matching chambers"
308      this.searchResultsTarget.replaceChildren(empty)
309      this.openSearchResults()
310      return
311    }
312
313    const options = items.map((item, index) => this.buildSearchOption(item, index))
314    this.searchResultsTarget.replaceChildren(...options)
315    this.openSearchResults()
316  }
317
318  buildSearchOption(item, index) {
319    const option = document.createElement("div")
320    option.id = `market-map-search-option-${index}`
321    option.className = "flex cursor-pointer items-start justify-between gap-3 px-3 py-2.5 outline-none hover:bg-zinc-50"
322    option.setAttribute("role", "option")
323    option.setAttribute("aria-selected", "false")
324    option.dataset.searchIndex = index.toString()
325    option.dataset.action = "click->market-map#selectSearchResult"
326
327    const identity = document.createElement("span")
328    identity.className = "min-w-0 flex-1"
329    const name = document.createElement("span")
330    name.className = "block truncate text-[13px] font-medium text-zinc-950"
331    name.textContent = this.locationDisplayName(item)
332    const sourceName = item.customer && this.locationDisplayName(item) !== item.name ? item.name : null
333    const place = document.createElement("span")
334    place.className = "mt-0.5 block truncate text-xs text-zinc-500"
335    place.textContent = [sourceName, this.locationLabel(item)].filter(
335Boolean).join(" · ")
336    identity.append(name, place)
337
338    const context = document.createElement("span")
339    context.className = "shrink-0 text-right"
340    if (item.customer) {
341      const badge = document.createElement("span")
342      badge.className = "block text-[10px] font-semibold uppercase tracking-wide text-emerald-700"
343      badge.textContent = "Customer"
344      context.appendChild(badge)
345    }
346    const revenue = this.revenueAmountLabel(item.annual_revenue_cents)
347    if (revenue) {
348      const amount = document.createElement("span")
349      amount.className = `${item.customer ? "mt-0.5 " : ""}block text-[11px] tabular-nums text-zinc-600`
350      amount.textContent = revenue
351      context.appendChild(amount)
352    }
353
354    option.append(identity, context)
355    return option
356  }
357
358  openSearchResults() {
359    this.searchResultsTarget.classList.remove("hidden")
360    this.searchTarget.setAttribute("aria-expanded", "true")
361  }
362
363  closeSearchResults({ clear = false } = {}) {
364    this.searchResultsTarget.classList.add("hidden")
365    this.searchTarget.setAttribute("aria-expanded", "false")
366    this.searchTarget.removeAttribute("aria-activedescendant")
367    this.activeSearchIndex = -1
368    if (!clear) return
369
370    this.searchItems = []
371    this.searchResultsTarget.replaceChildren()
372  }
373
374  setActiveSearchIndex(index) {
375    const options = Array.from(this.searchResultsTarget.querySelectorAll("[data-search-index]"))
376    this.activeSearchIndex = index
377    options.forEach((option, optionIndex) => {
378      const active = optionIndex === index
379      option.setAttribute("aria-selected", active ? "true" : "false")
380      option.classList.toggle("bg-zinc-100", active)
381    })
382    const activeOption = options[index]
383    if (!activeOption) return
384
385    this.searchTarget.setAttribute("aria-activedescendant", activeOption.id)
386    activeOption.scrollIntoView({ block: "nearest" })
387  }
388
389  activateSearchItem(index) {
390    const item = this.searchItems[index]
391    if (!item) return
392
393    window.clearTimeout(this.searchTimer)
394    window.clearTimeout(this.searchSuggestionTimer)
395    this.searchAbortController?.abort()
396    this.searchAbortController = null
397    this.searchTarget.value = item.name || this.locationDisplayName(item)
398    this.closeSearchResults({ clear: true })
399    this.showLocationDetails(item)
400
401    const latitude = Number(item.latitude)
402    const longitude = Number(item.longitude)
403    if (!this.map || !Number.isFinite(latitude) || !Number.isFinite(longitude)) return
404
405    this.cancelMainlandFitAdjustment()
406    this.cancelStateFitAdjustment()
407    this.map.panTo({ lat: latitude, lng: longitude })
408    this.map.setZoom(Math.max(this.map.getZoom() || 4, 10))
409  }
410
411  resetView() {
412    if (!this.map) return
413
414    this.regionTarget.value = ""
415    this.regionChanged()
416  }
417
418  focusLocation(event) {
419    const latitude = Number.parseFloat(event.currentTarget.dataset.latitude || "")
420    const longitude = Number.parseFloat(event.currentTarget.dataset.longitude || "")
421    if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return
422
423    const item = {
424      name: event.currentTarget.dataset.name || "Selected chamber",
425      city: event.currentTarget.dataset.location || "",
426      region: "",
427      latitude,
428      longitude,
429      customer: event.currentTarget.dataset.customer === "true",
430      annual_revenue_cents: event.currentTarget.dataset.annualRevenueCents || null,
431      annual_revenue_tax_period: event.currentTarget.dataset.annualRevenueTaxPeriod || null,
432      website_url: event.currentTarget.dataset.websiteUrl || null,
433      website_source_key: event.currentTarget.dataset.websiteSourceKey || null,
434      website_source_url: event.currentTarget.dataset.websiteSourceUrl || null,
435      ...this.locationDetailAttributesFromDataset(event.currentTarget.dataset),
436    }
437    this.showLocationDetails(item, { preformattedLocation: true })
438
439    if (this.map) {
440      this.cancelMainlandFitAdjustment()
441      this.cancelStateFitAdjustment()
442      this.map.panTo({ lat: latitude, lng: longitude })
443      this.map.setZoom(Math.max(this.map.getZoom() || 4, 10))
444      this.mapTarget.scrollIntoView({ behavior: "smooth", block: "center" })
445    }
446  }
447
448  buildMap() {
449    this.map = new window.google.maps.Map(this.mapTarget, {
450      center: { lat: 39.5, lng: -98.35 },
451      zoom: 4,
452      mapTypeControl: false,
453      streetViewControl: false,
454      fullscreenControl: true,
455      clickableIcons: false,
456      gestureHandling: "cooperative",
457      backgroundColor: "#e4e4e7",
458      isFractionalZoomEnabled: true,
459      renderingType: window.google.maps.RenderingType.RASTER,
460      styles: OVERVIEW_MAP_STYLES,
461    })
462    this.stateLabelsVisible = false
463    this.mapStyleListener = this.map.addListener("zoom_changed", () => this.updateMapStyles())
464
465    this.idleListener = this.map.addListener("idle", () => this.scheduleViewportRefresh())
466
467    if (this.selectedRegion()) {
468      this.requestStateFit()
469    } else if (this.hasOriginLatitudeValue && this.hasOriginLongitudeValue) {
470      this.showOriginArea()
471    } else {
472      this.fitMainlandView()
473    }
474  }
475
476  updateMapStyles() {
477    const stateLabelsVisible = (this.map?.getZoom() || 4) >= STATE_LABEL_MIN_ZOOM
478    if (stateLabelsVisible === this.stateLabelsVisible) return
479
480    this.stateLabelsVisible = stateLabelsVisible
481    this.map.setOptions({ styles: stateLabelsVisible ? MUTED_MAP_STYLES : OVERVIEW_MAP_STYLES })
482  }
483
484  fitMainlandView() {
485    this.cancelMainlandFitAdjustment()
486    this.cancelStateFitAdjustment()
487    this.pendingStateFit = null
488
489    this.mainlandFitListener = this.map.addListener("idle", () => {
490      const fittedZoom = this.map.getZoom()
491      this.cancelMainlandFitAdjustment()
492      if (!Number.isFinite(fittedZoom)) return
493
494      const zoomBoost = this.mapTarget.clientWidth < NARROW_MAP_WIDTH
495        ? NARROW_MAINLAND_ZOOM_BOOST
496        : MAINLAND_ZOOM_BOOST
497      const zoom = Math.min(fittedZoom + zoomBoost, MAINLAND_MAX_ZOOM)
498      if (zoom === MAINLAND_MAX_ZOOM) this.map.setCenter(MAINLAND_WIDE_CENTER)
499      this.map.setZoom(zoom)
500    })
501    this.map.fitBounds(CONTIGUOUS_US_BOUNDS, 36)
502  }
503
504  cancelMainlandFitAdjustment() {
505    this.mainlandFitListener?.remove()
506    this.mainlandFitListener = null
507  }
508
509  requestStateFit() {
510    const region = this.selectedRegion()
511    if (!this.map || !region) return
512
513    this.cancelMainlandFitAdjustment()
514    this.cancelStateFitAdjustment()
515    this.clearMarkers()
516    this.lastRenderedStateKey = null
517    this.pendingStateFit = region
518    this.resultStatusTarget.textContent = `Loading ${region} chambers…`
519    this.refreshPoints()
520  }
521
522  fitStateView(bounds) {
523    const north = Number(bounds?.north)
524    const south = Number(bounds?.south)
525    const east = Number(bounds?.east)
526    const west = Number(bounds?.west)
527    if (![north, south, east, west].every(Number.isFinite)) return false
528
529    this.cancelMainlandFitAdjustment()
530    this.cancelStateFitAdjustment()
531    this.stateFitListener = this.map.addListener("idle", () => {
532      const fittedZoom = this.map.getZoom()
533      this.cancelStateFitAdjustment()
534      if (Number.isFinite(fittedZoom) && fittedZoom > STATE_FIT_MAX_ZOOM) {
535        this.map.setZoom(STATE_FIT_MAX_ZOOM)
536      }
537    })
538    this.map.fitBounds({ north, south, east, west }, STATE_FIT_PADDING)
539    return true
540  }
541
542  cancelStateFitAdjustment() {
543    this.stateFitListener?.remove()
544    this.stateFitListener = null
545  }
546
547  selectedRegion() {
548    return this.regionTarget.value.trim()
549  }
550
551  syncRegionFormInput() {
552    if (this.hasRegionFormInputTarget) this.regionFormInputTarget.value = this.selectedRegion()
553  }
554
555  showOriginArea() {
556    const center = { lat: this.originLatitudeValue, lng: this.originLongitudeValue }
557
558    this.radiusCircle = new window.google.maps.Circle({
559      map: this.map,
560      center,
561      radius: Math.max(this.radiusMilesValue || 1, 1) * METERS_PER_MILE,
562      fillColor: "#f59e0b",
563      fillOpacity: 0.08,
564      strokeColor: "#b45309",
565      strokeOpacity: 0.65,
566      strokeWeight: 2,
567      clickable: false,
568    })
569    this.originMarker = new window.google.maps.Marker({
570      map: this.map,
571      position: center,
572      title: `${this.originNameValue || "Current customer"} — search center`,
573      label: { text: "★", color: "#ffffff", fontSize: "12px", fontWeight: "700" },
574      icon: this.circleIcon("#2563eb", 11, "#ffffff", 3),
575      zIndex: 500,
576    })
577
578    const bounds = this.radiusCircle.getBounds()
579    if (bounds) this.map.fitBounds(bounds, 48)
580  }
581
582  scheduleViewportRefresh() {
583    if (this.selectedRegion() && this.lastRenderedStateKey === this.stateRequestKe
583y()) return
584
585    window.clearTimeout(this.viewportTimer)
586    this.viewportTimer = window.setTimeout(() => this.refreshPoints(), VIEWPORT_DELAY_MS)
587  }
588
589  stateRequestKey() {
590    return [
591      this.selectedRegion(),
592      (this.map?.getZoom() || 4).toFixed(3),
593      this.kindTarget.value,
594      this.searchTarget.value.trim(),
595      this.minimumRevenueValue,
596      this.includeUnknownRevenueValue ? "1" : "0",
597    ].join(":")
598  }
599
600  async refreshPoints() {
601    if (!this.map || !this.hasPointsUrlValue) return
602
603    const bounds = this.map.getBounds()
604    if (!bounds) return
605
606    const northEast = bounds.getNorthEast()
607    const southWest = bounds.getSouthWest()
608    const url = new URL(this.pointsUrlValue, window.location.origin)
609    url.searchParams.set("north", northEast.lat().toString())
610    url.searchParams.set("east", northEast.lng().toString())
611    url.searchParams.set("south", southWest.lat().toString())
612    url.searchParams.set("west", southWest.lng().toString())
613    url.searchParams.set("zoom", (this.map.getZoom() || 4).toString())
614    url.searchParams.set("kind", this.kindTarget.value)
615    url.searchParams.set("minimum_revenue", this.minimumRevenueValue)
616    url.searchParams.set("include_unknown_revenue", this.includeUnknownRevenueValue ? "1" : "0")
617    if (this.selectedRegion()) url.searchParams.set("region", this.selectedRegion())
618    if (this.searchTarget.value.trim() !== "") url.searchParams.set("q", this.searchTarget.value.trim())
619
620    this.abortController?.abort()
621    this.abortController = new AbortController()
622    const sequence = ++this.fetchSequence
623    this.resultStatusTarget.textContent = "Loading chambers in this view…"
624
625    try {
626      const response = await fetch(url.toString(), {
627        headers: { Accept: "application/json" },
628        credentials: "same-origin",
629        signal: this.abortController.signal,
630      })
631      if (!response.ok) throw new Error(`Market request failed with ${response.status}`)
632
633      const payload = await response.json()
634      if (sequence !== this.fetchSequence || !this.element.isConnected) return
635
636      this.updateMarketSummary(payload.meta || {})
637
638      if (this.pendingStateFit === this.selectedRegion()) {
639        this.pendingStateFit = null
640        if (this.fitStateView(payload.meta?.bounds)) return
641      }
642
643      this.renderItems(Array.isArray(payload.items) ? payload.items : [])
644      this.updateResultStatus(payload.meta || {})
645      this.lastRenderedStateKey = this.selectedRegion() ? this.stateRequestKey() : null
646    } catch (error) {
647      if (error.name === "AbortError" || sequence !== this.fetchSequence) return
648
649      this.resultStatusTarget.textContent = "Chamber pins could not be loaded. Move the map or try again."
650    }
651  }
652
653  renderItems(items) {
654    this.hideMarkerTooltip()
655    const visibleIds = new Set()
656
657    items.forEach((item) => {
658      const key = `${item.type}-${item.id}`
659      visibleIds.add(key)
660      const marker = this.markers.get(key) || this.buildMarker(key)
661      marker.marketItem = item
662      marker.setPosition({ lat: Number(item.latitude), lng: Number(item.longitude) })
663      marker.setTitle(this.markerTitle(item))
664      marker.setIcon(this.markerIcon(item))
665      marker.setLabel(this.markerLabel(item))
666      marker.setZIndex(item.type === "location" && item.customer ? 300 : 200)
667      marker.setMap(this.map)
668    })
669
670    this.markers.forEach((marker, key) => {
671      if (visibleIds.has(key)) return
672
673      marker.setMap(null)
674      this.markers.delete(key)
675    })
676  }
677
678  buildMarker(key) {
679    const marker = new window.google.maps.Marker({ map: this.map })
680    marker.addListener("click", () => {
681      this.hideMarkerTooltip()
682      this.activateMarker(marker.marketItem)
683    })
684    marker.addListener("mouseover", (event) => this.showMarkerTooltip(marker.marketItem, event))
685    marker.addListener("mouseout", () => this.hideMarkerTooltip())
686    this.markers.set(key, marker)
687    return marker
688  }
689
690  activateMarker(item) {
691    if (!item || !this.map) return
692
693    this.cancelMainlandFitAdjustment()
694    const position = { lat: Number(item.latitude), lng: Number(item.longitude) }
695    if (item.type === "cluster") {
696      this.map.panTo(position)
697      this.map.setZoom(Math.min((this.map.getZoom() || 4) + 2, 18))
698      return
699    }
700
701    this.showLocationDetails(item)
702    this.map.panTo(position)
703  }
704
705  showLocationDetails(item, { preformattedLocation = false } = {}) {
706    this.showSelectedLocationMarker(item)
707    this.detailNameTarget.textContent = this.locationDisplayName(item)
708    this.detailLocationTarget.textContent = preformattedLocation
709      ? (item.city || "Location unavailable")
710      : this.locationLabel(item)
711    this.detailLocationTarget.classList.remove("hidden")
712
713    const badge = document.createElement("span")
714    badge.className = item.customer
715      ? "inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-800"
716      : "inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800"
717    badge.textContent = item.customer ? "Current customer" : "Potential customer"
718    this.detailBadgeTarget.replaceChildren(badge)
719
720    const revenueLabel = this.revenueAmountLabel(item.annual_revenue_cents)
721    this.detailRevenueTarget.textContent = revenueLabel || ""
722    this.toggleDetailRow(this.detailRevenueRowTarget, Boolean(revenueLabel))
723
724    const phone = this.stringValue(item.irs_phone)
725    const phoneHref = this.phoneHref(phone)
726    this.detailPhoneTarget.textContent = this.phoneDisplayLabel(phone) || ""
727    if (phoneHref) {
728      this.detailPhoneTarget.href = phoneHref
729    } else {
730      this.detailPhoneTarget.removeAttribute("href")
731    }
732    this.toggleDetailRow(this.detailPhoneRowTarget, Boolean(phone))
733
734    const expenses = this.revenueAmountLabel(item.irs_total_expenses_cents)
735    this.detailExpensesTarget.textContent = expenses || ""
736    this.toggleDetailRow(this.detailExpensesRowTarget, Boolean(expenses))
737
738    const operatingSurplus = this.numericValue(item.irs_operating_surplus_cents)
739    const operatingResult = operatingSurplus === null
740      ? null
741      : this.revenueAmountLabel(Math.abs(operatingSurplus))
742    const operatingDeficit = operatingSurplus !== null && operatingSurplus < 0
743    this.detailOperatingResultLabelTarget.textContent = operatingDeficit ? "Operating deficit" : "Operating surplus"
744    this.detailOperatingResultTarget.textContent = operatingResult || ""
745    this.detailOperatingResultTarget.classList.toggle("text-rose-700", operatingDeficit)
746    this.detailOperatingResultTarget.classList.toggle("text-emerald-700", operatingSurplus !== null && !operatingDeficit)
747    this.detailOperatingResultTarget.classList.toggle("text-zinc-900", operatingSurplus === null)
748    this.toggleDetailRow(this.detailOperatingResultRowTarget, Boolean(operatingResult))
749
750    const employees = this.integerLabel(item.irs_employee_count)
751    this.detailEmployeesTarget.textContent = employees || ""
752    this.toggleDetailRow(this.detailEmployeesRowTarget, Boolean(employees))
753
754    const assets = this.revenueAmountLabel(item.irs_total_assets_cents)
755    this.detailAssetsTarget.textContent = assets || ""
756    this.toggleDetailRow(this.detailAssetsRowTarget, Boolean(assets))
757
758    const netAssets = this.revenueAmountLabel(item.irs_net_assets_cents)
759    this.detailNetAssetsTarget.textContent = netAssets || ""
760    this.toggleDetailRow(this.detailNetAssetsRowTarget, Boolean(netAssets))
761
762    const membershipDues = this.revenueAmountLabel(item.irs_membership_dues_cents)
763    this.detailMembershipDuesTarget.textContent = membershipDues || ""
764    this.toggleDetailRow(this.detailMembershipDuesRowTarget, Boolean(membershipDues))
765
766    const programRevenue = this.revenueAmountLabel(item.irs_program_service_revenue_cents)
767    this.detailProgramRevenueTarget.textContent = programRevenue || ""
768    this.toggleDetailRow(this.detailProgramRevenueRowTarget, Boolean(programRevenue))
769
770    const executive = [
771      this.stringValue(item.irs_principal_officer_name),
772      this.stringValue(item.irs_principal_officer_title),
773    ].filter(Boolean).join(" · ")
774    this.detailExecutiveTarget.textContent = executive
775    this.toggleDetailRow(this.detailExecutiveRowTarget, Boolean(executive))
776
777    const filing = [
778      this.returnTypeLabel(item.irs_return_type),
779      this.revenueTaxPeriodMonthLabel(item.irs_filing_tax_period),
780    ].filter(Boolean).join(" · ")
781    this.detailFilingTarget.textContent = filing
782    this.toggleDetailRow(this.detailFilingRowTarget, Boolean(filing))
783
784    const website = this.safeWebsiteUrl(item.website_url)
785    if (website) {
786      this.detailWebsiteTarget.href = website.toString()
787      this.detailWebsiteTarget.textContent = website.hostname.replace(/^www\./i, "")
788    } else {
789      this.detailWebsiteTarget.removeAttribute("href")
790      this.detailWebsiteTarget.textContent = ""
791    }
792    this.toggleDetailRow(this.detailWebsiteRowTarget, Boolean(website))
793
794    const websiteSource = website ? this.safeWebsiteUrl(item.website_source_url) : null
795    if (websiteSource) {
796      const sourceIsUsChamber = item.website_source_key === "us_chamber_directory"
797      const sourceIsIrs = item.website_source_key === "irs_form_990_2025"
798      this.detailWebsiteSourceTarget.href = websiteSource.toString()
799      this.detailWebsiteSourceTarget.textContent = sourceIsUsChamber ? "U.S. Chamber" : sourceIsIrs ? "IRS filing" : "Source"
800      this.detailWebsiteSourceTarget.title = sourceIsUsChamber
801        ? "Website source: U.S. Chamber directory"
802        : sourceIsIrs ? "Website source: IRS filing" : "Website source"
803      this.detailWebsiteSourceTarget.classList.remove("hidden")
804    } else {
805      this.detailWebsiteSourceTarget.removeAttribute("href")
806      this.detailWebsiteSourceTarget.removeAttribute("title")
807      this.detailWebsiteSourceTarget.classList.add("hidden")
808    }
809
810    const mission = this.conciseMission(item.irs_mission)
811    this.detailMissionTarget.textContent = mission || ""
812    this.detailMissionRowTarget.classList.toggle("hidden", !mission)
813
814    const hasMetadata = [
815      revenueLabel,
816      website,
817      phone,
818      expenses,
819      operatingResult,
820      employees,
821      assets,
822      netAssets,
823      membershipDues,
824      programRevenue,
825      executive,
826      filing,
827    ].some(Boolean)
828    this.detailMetaTarget.classList.toggle("hidden", !hasMetadata)
829
830    this.detailIconTarget.classList.toggle("bg-emerald-100", item.customer)
831    this.detailIconTarget.classList.toggle("text-emerald-800", item.customer)
832    this.detailIconTarget.classList.toggle("bg-amber-100", !item.customer)
833    this.detailIconTarget.classList.toggle("text-amber-800", !item.customer)
834  }
835
836  showSelectedLocationMarker(item) {
837    const latitude = Number(item?.latitude)
838    const longitude = Number(item?.longitude)
839    if (!this.map || !Number.isFinite(latitude) || !Number.isFinite(longitude)) return
840
841    if (!this.selectedLocationMarker) {
842      this.selectedLocationMarker = new window.google.maps.Marker({
843        clickable: false,
844        icon: this.circleIcon("#2563eb", 10, "#ffffff", 3),
845        map: this.map,
846        zIndex: 600,
847      })
848    }
849
850    this.selectedLocationMarker.setPosition({ lat: latitude, lng: longitude })
851    this.selectedLocationMarker.setMap(this.map)
852  }
853
854  updateMarketSummary(meta) {
855    if (!this.selectedRegion()) {
856      this.restoreMarketSummary()
857      return
858    }
859
860    const knownCount = Number(meta.total_point_count)
861    const customerCount = Number(meta.customer_count)
862    if (!Number.isFinite(knownCount) || !Number.isFinite(customerCount)) return
863
864    this.summaryKnownTarget.textContent = knownCount.toLocaleString()
865    this.summaryProspectsTarget.textContent = Math.max(knownCount - customerCount, 0).toLocaleString()
866    this.summaryCustomersTarget.textContent = customerCount.toLocaleString()
867    this.summaryCustomerTotalTarget.classList.add("hidden")
868    const coverage = knownCount > 0 ? customerCount / knownCount * 100 : 0
869    this.summaryCoverageTarget.textContent = `${coverage.toFixed(2)}%`
870  }
871
872  restoreMarketSummary() {
873    this.summaryKnownTarget.textContent = this.summaryKnownTarget.dataset.nationalValue
874    this.summaryProspectsTarget.textContent = this.summaryProspectsTarget.dataset.nationalValue
875    this.summaryCustomersTarget.textContent = this.summaryCustomersTarget.dataset.nationalValue
876    this.summaryCustomerTotalTarget.classList.remove("hidden")
877    this.summaryCoverageTarget.textContent = this.summaryCoverageTarget.dataset.nationalValue
878  }
879
880  updateResultStatus(meta) {
881    const pointCount = Number(meta.point_count || 0)
882    const markerCount = Number(meta.marker_count || 0)
883    const region = this.selectedRegion()
884
885    if (pointCount === 0) {
886      this.resultStatusTarget.textContent = region
887        ? `No matching chambers in ${region}.`
888        : "No matching chambers in this view."
889      return
890    }
891
892    const pointLabel = pointCount === 1 ? "chamber" : "chambers"
893    const markerLabel = markerCount === 1 ? "marker" : "markers"
894    const scopeLabel = region ? `in ${region}` : "in view"
895    this.resultStatusTarget.textContent = `${pointCount.toLocaleStr
895ing()} ${pointLabel} ${scopeLabel} · ${markerCount.toLocaleString()} ${markerLabel}`
896  }
897
898  markerTitle(item) {
899    const customerNames = this.customerNames(item)
900    const customerLabel = customerNames.length > 0
901      ? ` — current customers: ${customerNames.join(", ")}`
902      : ""
903
904    if (item.type === "cluster") {
905      return `${Number(item.count).toLocaleString()} chambers${customerLabel} — click to zoom in`
906    }
907
908    const displayName = this.locationDisplayName(item)
909    const customerStatus = item.customer ? " — current customer" : ""
910    const revenue = this.locationTooltipRevenueLabel(
911      item.annual_revenue_cents,
912      item.annual_revenue_tax_period
913    )
914    return `${displayName}${customerStatus} — ${revenue}`
915  }
916
917  showMarkerTooltip(item, event) {
918    if (item?.type === "location") {
919      this.showLocationTooltip(item, event)
920      return
921    }
922
923    const customerNames = this.customerNames(item)
924    if (customerNames.length === 0) return
925
926    event?.domEvent?.currentTarget?.removeAttribute?.("title")
927    this.hideMarkerTooltip()
928    const tooltip = document.createElement("div")
929    tooltip.className = "member-note-tooltip"
930    tooltip.setAttribute("role", "tooltip")
931    tooltip.dataset.marketMarkerTooltip = ""
932    tooltip.append(
933      this.markerTooltipLine(
934        customerNames.length === 1 ? "Current customer" : "Current customers",
935        "block text-[11px] font-semibold text-zinc-500"
936      ),
937      ...customerNames.map((name, index) => this.markerTooltipLine(
938        name,
939        `${index === 0 ? "mt-1" : "mt-0.5"} block text-xs leading-5 text-zinc-800`
940      ))
941    )
942    this.showMarkerTooltipElement(tooltip, event)
943  }
944
945  showLocationTooltip(item, event) {
946    const isCustomer = Boolean(item.customer)
947    const displayName = this.locationDisplayName(item)
948    const revenue = this.locationTooltipRevenueLabel(
949      item.annual_revenue_cents,
950      item.annual_revenue_tax_period
951    )
952
953    event?.domEvent?.currentTarget?.removeAttribute?.("title")
954    this.hideMarkerTooltip()
955    const tooltip = document.createElement("div")
956    tooltip.className = "member-note-tooltip"
957    tooltip.setAttribute("role", "tooltip")
958    tooltip.dataset.marketMarkerTooltip = ""
959    const lines = []
960    if (isCustomer) {
961      lines.push(this.markerTooltipLine(
962        "Current customer",
963        "block text-[10px] font-semibold uppercase tracking-wide text-emerald-700"
964      ))
965    }
966    lines.push(
967      this.markerTooltipLine(
968        displayName,
969        `${isCustomer ? "mt-1 " : ""}block text-xs font-semibold leading-4 text-zinc-900`
970      ),
971      this.markerTooltipLine(
972        revenue,
973        "mt-1 block text-[11px] leading-4 tabular-nums text-zinc-500"
974      )
975    )
976    tooltip.append(...lines)
977    this.showMarkerTooltipElement(tooltip, event)
978  }
979
980  showMarkerTooltipElement(tooltip, event) {
981    document.body.appendChild(tooltip)
982    this.markerTooltip = tooltip
983    window.addEventListener("scroll", this.hideMarkerTooltipOnViewportChange, true)
984    window.addEventListener("resize", this.hideMarkerTooltipOnViewportChange)
985    this.positionMarkerTooltip(event)
986  }
987
988  hideMarkerTooltip() {
989    window.removeEventListener("scroll", this.hideMarkerTooltipOnViewportChange, true)
990    window.removeEventListener("resize", this.hideMarkerTooltipOnViewportChange)
991    this.markerTooltip?.remove()
992    this.markerTooltip = null
993  }
994
995  positionMarkerTooltip(event) {
996    if (!this.markerTooltip) return
997
998    const pointerEvent = event?.domEvent
999    const clientX = Number(pointerEvent?.clientX)
1000    const clientY = Number(pointerEvent?.clientY)
1001    if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) {
1002      this.hideMarkerTooltip()
1003      return
1004    }
1005
1006    const margin = 8
1007    const gap = 8
1008    const tooltipRect = this.markerTooltip.getBoundingClientRect()
1009    let left = clientX + gap
1010    let top = clientY + gap
1011
1012    if (left + tooltipRect.width > window.innerWidth - margin) {
1013      left = clientX - tooltipRect.width - gap
1014    }
1015    if (top + tooltipRect.height > window.innerHeight - margin) {
1016      top = clientY - tooltipRect.height - gap
1017    }
1018
1019    this.markerTooltip.style.left = `${Math.max(left, margin)}px`
1020    this.markerTooltip.style.top = `${Math.max(top, margin)}px`
1021  }
1022
1023  customerNames(item) {
1024    return Array.isArray(item?.customer_names)
1025      ? item.customer_names.map((name) =>
1025 String(name).trim()).filter(Boolean)
1026      : []
1027  }
1028
1029  locationDisplayName(item) {
1030    const customerName = item?.customer ? this.customerNames(item)[0] : null
1031    return customerName || item?.name || "Chamber"
1032  }
1033
1034  markerTooltipLine(text, className) {
1035    const line = document.createElement("span")
1036    line.className = className
1037    line.textContent = text
1038    return line
1039  }
1040
1041  markerIcon(item) {
1042    if (item.type === "cluster") {
1043      const customerCount = Number(item.customer_count || 0)
1044      const fill = customerCount > 0 ? "#059669" : "#b45309"
1045      const scale = Math.min(10 + Math.sqrt(Math.max(Number(item.count), 1)) * 0.9, 16)
1046      return this.circleIcon(fill, scale, "#ffffff", 2)
1047    }
1048
1049    return item.customer
1050      ? this.circleIcon("#059669", 9, "#ffffff", 3)
1051      : this.circleIcon("#f59e0b", 7, "#ffffff", 2)
1052  }
1053
1054  markerLabel(item) {
1055    if (item.type !== "cluster") return null
1056
1057    const count = Number(item.count || 0)
1058    return {
1059      text: count > 999 ? "999+" : count.toString(),
1060      color: "#ffffff",
1061      fontSize: count > 9 ? "9px" : "10px",
1062      fontWeight: "700",
1063    }
1064  }
1065
1066  circleIcon(fillColor, scale, strokeColor, strokeWeight) {
1067    return {
1068      path: window.google.maps.SymbolPath.CIRCLE,
1069      scale,
1070      fillColor,
1071      fillOpacity: 0.96,
1072      strokeColor,
1073      strokeWeight,
1074    }
1075  }
1076
1077  locationLabel(item) {
1078    const cityRegion = [item.city, item.region].filter(Boolean).join(", ")
1079    return [cityRegion, item.postal_code].filter(Boolean).join(" ") || "Location unavailable"
1080  }
1081
1082  locationDetailAttributesFromDataset(dataset) {
1083    return {
1084      irs_filing_tax_period: this.datasetValue(dataset.irsFilingTaxPeriod),
1085      irs_return_type: this.datasetValue(dataset.irsReturnType),
1086      irs_phone: this.datasetValue(dataset.irsPhone),
1087      irs_total_expenses_cents: this.datasetValue(dataset.irsTotalExpensesCents),
1088      irs_operating_surplus_cents: this.datasetValue(dataset.irsOperatingSurplusCents),
1089      irs_total_assets_cents: this.datasetValue(dataset.irsTotalAssetsCents),
1090      irs_total_liabilities_cents: this.datasetValue(dataset.irsTotalLiabilitiesCents),
1091      irs_net_assets_cents: this.datasetValue(dataset.irsNetAssetsCents),
1092      irs_employee_count: this.datasetValue(dataset.irsEmployeeCount),
1093      irs_payroll_cents: this.datasetValue(dataset.irsPayrollCents),
1094      irs_membership_dues_cents: this.datasetValue(dataset.irsMembershipDuesCents),
1095      irs_program_service_revenue_cents: this.datasetValue(dataset.irsProgramServiceRevenueCents),
1096      irs_contributions_grants_cents: this.datasetValue(dataset.irsContributionsGrantsCents),
1097      irs_principal_officer_name: this.datasetValue(dataset.irsPrincipalOfficerName),
1098      irs_principal_officer_title: this.datasetValue(dataset.irsPrincipalOfficerTitle),
1099      irs_mission: this.datasetValue(dataset.irsMission),
1100      irs_formation_year: this.datasetValue(dataset.irsFormationYear),
1101    }
1102  }
1103
1104  datasetValue(value) {
1105    return typeof value === "string" && value.trim() !== "" ? value : null
1106  }
1107
1108  stringValue(value) {
1109    const text = String(value || "").replace(/\s+/g, " ").trim()
1110    return text || null
1111  }
1112
1113  numericValue(value) {
1114    if (value === null || value === undefined) return null
1115    if (typeof value === "string" && value.trim() === "") return null
1116
1117    const number = Number(value)
1118    return Number.isFinite(number) ? number : null
1119  }
1120
1121  integerLabel(value) {
1122    const number = this.numericValue(value)
1123    if (number === null) return null
1124
1125    return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(number)
1126  }
1127
1128  phoneHref(value) {
1129    if (!value) return null
1130
1131    const phone = value.replace(/(?!^)\+|[^\d+]/g, "")
1132    return phone.replace(/\D/g, "").length >= 7 ? `tel:${phone}` : null
1133  }
1134
1135  phoneDisplayLabel(value) {
1136    if (!value) return null
1137
1138    const digits = value.replace(/\D/g, "")
1139    if (digits.length === 10) {
1140      return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`
1141    }
1142
1143    return value
1144  }
1145
1146  returnTypeLabel(value) {
1147    const text = this.stringValue(value)
1148    if (!text) return null
1149
1150    const normalized = text.toUpperCase().replace(/[^A-Z0-9]/g, "")
1151    if (normalized === "990") return "Form 990"
1152    if (normalized === "990EZ") return "Form 990-EZ"
1153
1154    return text
1155  }
1156
1157  conciseMission(value) {
1158    const mission = this.stringValue(value)
1159    if (!mission || mission.length < 24) return null
1160    if (mission.length <= 320) return mission
1161
1162    const shortened = mission.slice(0, 319)
1163    const lastSpace = shortened.lastIndexOf(" ")
1164    return `${shortened.slice(0, lastSpace > 240 ? lastSpace : 319)}…`
1165  }
1166
1167  locationTooltipRevenueLabel(centsValue, taxPeriod) {
1168    const amount = this.revenueAmountLabel(centsValue)
1169    if (!amount) return "IRS revenue unavailable"
1170
1171    const period = this.revenueTaxPeriodMonthLabel(taxPeriod)
1172    return [`IRS revenue ${amount}`, period].filter(Boolean).join(" · ")
1173  }
1174
1175  revenueAmountLabel(centsValue) {
1176    if (centsValue === null || centsValue === undefined) return null
1177    if (typeof centsValue === "string" && centsValue.trim() === "") return null
1178
1179    const cents = Number(centsValue)
1180    if (!Number.isFinite(cents)) return null
1181
1182    return new Intl.NumberFormat("en-US", {
1183      style: "currency",
1184      currency: "USD",
1185      maximumFractionDigits: 0,
1186    }).format(cents / 100)
1187  }
1188
1189  revenueTaxPeriodMonthLabel(value) {
1190    const match = String(value || "").match(/^(\d{4})(\d{2})$/)
1191    if (!match) return null
1192
1193    const year = Number(match[1])
1194    const month = Number(match[2])
1195    if (month < 1 || month > 12) return null
1196
1197    const date = new Date(Date.UTC(year, month - 1, 1))
1198    return new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric", timeZone: "UTC" }).format(date)
1199  }
1200
1201  safeWebsiteUrl(value) {
1202    if (!value) return null
1203
1204    try {
1205      const url = new URL(value, window.location.origin)
1206      return ["http:", "https:"].includes(url.protocol) ? url : null
1207    } catch (_error) {
1208      return null
1209    }
1210  }
1211
1212  toggleDetailRow(row, visible) {
1213    row.classList.toggle("hidden", !visible)
1214    row.classList.toggle("flex", visible)
1215  }
1216
1217  hideMapStatus() {
1218    this.mapStatusTarget.classList.add("hidden")
1219  }
1220
1221  showMapError(message) {
1222    this.mapStatusTarget.textContent = message
1223    this.mapStatusTarget.classList.remove("hidden")
1224    this.resultStatusTarget.textContent = "Map unavailable"
1225  }
1226
1227  mapUnavailableMessage() {
1228    if (this.apiKeyValue.trim() !== "") {
1229      return "The map is unavailable."
1230    }
1231
1232    return `Google Maps needs an API key. Set GOOGLE_MAPS_API_KEY, allow ${window.location.origin}/* in its website restrictions, then restart bin/dev.`
1233  }
1234
1235  clearMarkers() {
1236    this.hideMarkerTooltip()
1237    this.markers?.forEach((marker) => marker.setMap(null))
1238    this.markers?.clear()
1239  }
1240}

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.