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.