1document.addEventListener("DOMContentLoaded", function () { 2 // Hardcode token/style in JS (as requested) 3 mapboxgl.accessToken = 4 "pk.eyJ1Ijoic3Jhc2hpZGpyMTIzIiwiYSI6ImNtMTU3ejd4ODA2OG8ycW9wdGhmcjhpcWUifQ.AoP23o6PwAusMSF4znQkIw"; 5 6 const MAPBOX_STYLE = "mapbox://styles/srashidjr123/cml4cfft0001r01s222186q4d"; 7 8 // Layer IDs 9 const REGIONS_LAYER = "shading"; 10 const REGIONS_OUTLINE_LAYER = "shading-copy"; 11 const HUBS_LAYER = "hubs"; 12 const BOTTLENECKS_LAYER = "bottlenecks"; 13 14 // Layers added for the address-search feature. 15 // - COUNTY_POINTS_LAYER: county-centroid layer already in the Mapbox style 16 // (the user added `albersusa-points` via Mapbox Studio). 17 // - ALL_BOTTLENECKS_LAYER: layer added once the 18 // `srashidjr123.all_bottlenecks_ranked_albersusa` tileset is published. 19 // Source + source-layer names are read from the live layer definitions at 20 // runtime so the JS does not have to know the exact tileset names. 21 const COUNTY_POINTS_LAYER = "albersusa-points"; 22 const ALL_BOTTLENECKS_LAYER = "all-bottlenecks"; 23 24 // Suffixes Google may attach to the county component that the Mapbox layer 25 // does not include. Order matters: longest match first. 26 const COUNTY_SUFFIXES = [ 27 " city and borough", 28 " census area", 29 " municipality", 30 " borough", 31 " parish", 32 " county", 33 ]; 34 35 // Your bounds (lng/lat) 36 const BOUNDS = [ 37 [-24.027421939301178, -13.139816660772302], 38 [20.715666482029366, 12.763659], 39 ]; 40 41 function format1(x) { 42 const n = Number(x); 43 if (!isFinite(n)) return x; 44 return n.toFixed(1); 45 } 46 47 function esc(s) { 48 return String(s ?? "") 49 .replace(/&/g, "&") 50 .replace(/</g, "<") 51 .replace(/>/g, ">") 52 .replace(/"/g, """) 53 .replace(/'/g, "'"); 54 } 55 56 function normalizeCountyName(name) { 57 if (!name) return null; 58 let n = String(name).trim().toLowerCase(); 59 for (const suf of COUNTY_SUFFIXES) { 60 if (n.endsWith(suf)) { 61 n = n.slice(0, -suf.length).trim(); 62 break; 63 } 64 } 65 return n; 66 } 67 68 function pickComponent(components, type) { 69 if (!Array.isArray(components)) return null; 70 return components.find((c) => Array.isArray(c.types) && c.types.includes(type)) || null; 71 } 72 73 function waitForGooglePlaces(timeoutMs = 12000) { 74 return new Promise((resolve, reject) => { 75 const ready = () => 76 window.google && 77 window.google.maps && 78 window.google.maps.places && 79 (typeof window.google.maps.places.PlaceAutocompleteElement === "function" || 80 typeof window.google.maps.places.Autocomplete === "function"); 81 if (ready()) return resolve(); 82 const start = Date.now(); 83 const t = setInterval(() => { 84 if (ready()) { 85 clearInterval(t); 86 resolve(); 87 } else if (Date.now() - start > timeoutMs) { 88 clearInterval(t); 89 reject(new Error("Google Places API not available")); 90 } 91 }, 150); 92 }); 93 } 94 95 // Normalize a place result's address components into the legacy shape 96 // { types, long_name, short_name } so downstream code is one path. 97 function componentsFromPlace(place) { 98 if (!place) return []; 99 if (Array.isArray(place.addressComponents)) { 100 return place.addressComponents.map((c) => ({ 101 types: c.types || [], 102 long_name: c.longText || c.long_name || "", 103 short_name: c.shortText || c.short_name || "", 104 })); 105 } 106 if (Array.isArray(place.address_components)) return place.address_components; 107 return []; 108 } 109 110 function initAddressSearch(map, wrap) { 111 if (!wrap) return; 112 const input = wrap.querySelector(".nam-address-search"); 113 const clearBtn = wrap.querySelector(".nam-search-clear"); 114 const msgEl = wrap.querySelector(".nam-search-msg"); 115 if (!input) return; 116 117 const popups = []; 118 const setMessage = (text) => { 119 if (msgEl) msgEl.textContent = text || ""; 120 }; 121 const clearPopups = () => { 122 while (popups.length) popups.pop().remove(); 123 }; 124 125 const resetMap = () => { 126 clearPopups(); 127 setMessage(""); 128 if (map.getLayer(ALL_BOTTLENECKS_LAYER)) { 129 try {
130 map.setLayoutProperty(ALL_BOTTLENECKS_LAYER, "visibility", "none"); 131 map.setFilter(ALL_BOTTLENECKS_LAYER, ["==", ["get", "county_fips"], "__none__"]); 132 } catch (e) {} 133 } 134 map.fitBounds(BOUNDS, { padding: 15, duration: 500 }); 135 }; 136 137 if (clearBtn) { 138 clearBtn.addEventListener("click", () => { 139 const current = wrap.querySelector(".nam-address-search"); 140 if (current) { 141 try { 142 if ("value" in current) current.value = ""; 143 } catch (e) {} 144 const innerInput = 145 current.tagName && current.tagName.toLowerCase().startsWith("gmp-") 146 ? current.querySelector && current.querySelector("input") 147 : null; 148 if (innerInput) { 149 try { innerInput.value = ""; } catch (e) {} 150 } 151 } 152 setMessage(""); 153 resetMap(); 154 }); 155 } 156 157 // Lazy-load the 745-row bottleneck dataset (real + projected coordinates). 158 let bottlenecksPromise = null; 159 const loadBottlenecks = () => { 160 if (bottlenecksPromise) return bottlenecksPromise; 161 const url = 162 (window.NAM_FREIGHT_MAP && window.NAM_FREIGHT_MAP.bottlenecks_url) || ""; 163 if (!url) { 164 bottlenecksPromise = Promise.resolve([]); 165 return bottlenecksPromise; 166 } 167 bottlenecksPromise = fetch(url, { credentials: "same-origin" }) 168 .then((r) => (r.ok ? r.json() : [])) 169 .catch(() => []); 170 return bottlenecksPromise; 171 }; 172 173 // Haversine distance between two WGS84 points, returns miles. 174 const haversineMiles = (lng1, lat1, lng2, lat2) => { 175 const R = 3958.7613; // Earth radius, statute miles 176 const toRad = (d) => (d * Math.PI) / 180; 177 const dLat = toRad(lat2 - lat1); 178 const dLng = toRad(lng2 - lng1); 179 const a = 180 Math.sin(dLat / 2) ** 2 + 181 Math.cos(toRad(lat1)) * 182 Math.cos(toRad(lat2)) * 183 Math.sin(dLng / 2) ** 2; 184 return 2 * R * Math.asin(Math.min(1, Math.sqrt(a))); 185 }; 186 187 const placeLatLng = (place) => { 188 if (!place) return null; 189 // New API: place.location is a LatLng instance 190 const loc = place.location; 191 if (loc && typeof loc.lat === "function" && typeof loc.lng === "function") { 192 return { lat: loc.lat(), lng: loc.lng() }; 193 } 194 if (loc && typeof loc.lat === "number" && typeof loc.lng === "number") { 195 return { lat: loc.lat, lng: loc.lng }; 196 } 197 // Legacy API: place.geometry.location 198 const geo = place.geometry && place.geometry.location; 199 if (geo) { 200 const lat = typeof geo.lat === "function" ? geo.lat() : geo.lat; 201 const lng = typeof geo.lng === "function" ? geo.lng() : geo.lng; 202 if (typeof lat === "number" && typeof lng === "number") { 203 return { lat, lng }; 204 } 205 } 206 return null; 207 }; 208 209 const showPopups = (items, anchor) => { 210 if (!items.length) return; 211 let lng, lat; 212 if (anchor && anchor.length === 2) { 213 [lng, lat] = anchor; 214 } else { 215 // Centroid of the picked bottlenecks 216 lng = items.reduce((s, p) => s + p.lng, 0) / items.length; 217 lat = items.reduce((s, p) => s + p.lat, 0) / items.length; 218 } 219 const heading = 220 items.length > 1 221 ? `${items.length} BOTTLENECKS NEARBY` 222 : "BOTTLENECK NEARBY"; 223 const listItems = items 224 .map((p) => { 225 const bits = []; 226 if (p.distance_miles != null && isFinite(Number(p.distance_miles))) { 227 bits.push(`${esc(format1(p.distance_miles))} mi away`); 228 } 229 if (p.hours_delayed != null && isFinite(Number(p.hours_delayed))) { 230 bits.push(`Hours delayed: ${esc(format1(p.hours_delayed))}`); 231 } 232 const subLine = bits.length 233 ? `<div class="nam-dialog__sub">${bits.join(" \u00B7 ")}</div>` 234 : ""; 235 return ( 236 `<li class="nam-dialog__item">` + 237 `<div class="nam-dialog__title">${esc(p.descriptive_name || "")}</div>` + 238 subLine + 239 `</li>` 240 ); 241 }) 242 .join(""); 243 const html = 244 `<div class="nam-dialog nam-dialog--list">` + 245 `<div class="nam-dialog__header">${esc(heading)}</div>` + 246 `<ol class="nam-dialog__list">${listItems}</ol>` + 247 `</div>`; 248 const popup = new mapboxgl.Popup({ 249 closeButton: true, 250 closeOnClick: false, 251 maxWidth: "520px", 252 offset: 16, 253 anchor: "bottom", 254 className: "nam-popup", 255 }) 256 .setLngLat([lng, lat]) 257 .setHTML(html) 258 .addTo(map); 259 popups.push(popup); 260 }; 261 262 const RADIUS_MILES = 263 (window.NAM_FREIGHT_MAP && Number(window.NAM_FREIGHT_MAP.radius_miles)) || 75; 264 265 const handlePlace = async (place) => { 266 clearPopups(); 267 setMessage(""); 268 269 const ll = placeLatLng(place); 270 if (!ll) { 271 setMessage("Could not read the address location."); 272 return; 273 } 274 275 const all = await loadBottlenecks(); 276 if (!all || !all.length) { 277 setMessage("Bottleneck data is not loaded yet, try again in a moment."); 278 return; 279 } 280 281 const within = []; 282 for (const b of all) {
283 if (typeof b.lng !== "number" || typeof b.lat !== "number") continue; 284 const d = haversineMiles(ll.lng, ll.lat, b.lng, b.lat); 285 if (d <= RADIUS_MILES) within.push(Object.assign({ distance_miles: d }, b)); 286 } 287 288 if (!within.length) { 289 setMessage( 290 `No major freight bottlenecks within ${RADIUS_MILES} miles of that address.` 291 ); 292 return; 293 } 294 295 // Sort by hours delayed (largest first); ties broken by closer distance. 296 within.sort((a, b) => { 297 const ha = Number(a.hours_delayed) || 0; 298 const hb = Number(b.hours_delayed) || 0; 299 if (hb !== ha) return hb - ha; 300 return a.distance_miles - b.distance_miles; 301 }); 302 303 // Highlight matching bottlenecks on the map via an `in` filter on id. 304 const ids = within.map((b) => Number(b.id)).filter((n) => Number.isFinite(n)); 305 if (ids.length && map.getLayer(ALL_BOTTLENECKS_LAYER)) { 306 try { 307 map.setFilter(ALL_BOTTLENECKS_LAYER, [ 308 "in", 309 ["get", "id"], 310 ["literal", ids], 311 ]); 312 map.setLayoutProperty(ALL_BOTTLENECKS_LAYER, "visibility", "visible"); 313 } catch (e) {} 314 } 315 316 // Fit map to the projected coordinates of the matching bottlenecks. 317 const bounds = new mapboxgl.LngLatBounds(); 318 const items = within.map((b) => ({ 319 lng: b.px, 320 lat: b.py, 321 descriptive_name: b.descriptive_name, 322 hours_delayed: b.hours_delayed, 323 distance_miles: b.distance_miles, 324 })); 325 items.forEach((p) => bounds.extend([p.lng, p.lat])); 326 327 map.fitBounds(bounds, { 328 padding: { top: 110, bottom: 90, left: 90, right: 90 }, 329 maxZoom: 9, 330 duration: 700, 331 }); 332 333 setMessage( 334 `${within.length} bottleneck${within.length === 1 ? "" : "s"} within ${RADIUS_MILES} miles.` 335 ); 336 showPopups(items, null); 337 }; 338 339 waitForGooglePlaces() 340 .then(() => { 341 const places = google.maps.places; 342 if (typeof places.PlaceAutocompleteElement === "function") { 343 let pa; 344 try { 345 pa = new places.PlaceAutocompleteElement({ includedRegionCodes: ["us"] }); 346 } catch (e) { 347 try { 348 pa = new places.PlaceAutocompleteElement(); 349 } catch (e2) { 350 setMessage("Address search unavailable."); 351 return; 352 } 353 } 354 pa.className = "nam-address-search"; 355 if (input.placeholder) { 356 try { pa.placeholder = input.placeholder; } catch (e) {} 357 } 358 input.replaceWith(pa); 359 pa.addEventListener("gmp-select", async (ev) => { 360 const pred = ev && ev.placePrediction; 361 if (!pred) { 362 setMessage("Please pick an address from the suggestions."); 363 return; 364 } 365 const place = pred.toPlace(); 366 try { 367 await place.fetchFields({ fields: ["addressComponents", "location"] }); 368 } catch (e) { 369 setMessage("Could not fetch address details."); 370 return; 371 } 372 handlePlace(place); 373 }); 374 } else if (typeof places.Autocomplete === "function") { 375 const autocomplete = new places.Autocomplete(input, { 376 types: ["address"], 377 componentRestrictions: { country: "us" }, 378 fields: ["address_components", "formatted_address", "geometry"], 379 }); 380 autocomplete.addListener("place_changed", () => { 381 handlePlace(autocomplete.getPlace()); 382 }); 383 } else { 384 setMessage("Address search is unavailable on this site."); 385 } 386 }) 387 .catch(() => { 388 setMessage("Address search is unavailable right now."); 389 }); 390 } 391 392 /* ========================= 393 SPLASH STAGE (kiosk shortcode [freight_map_splash]) 394 ========================= */ 395 function initSplashStages() { 396 const stages = document.querySelectorAll(".nam-splash-stage[data-nam-splash]");
397 stages.forEach((stage) => { 398 const splash = stage.querySelector(".nam-splash"); 399 const mapstage = stage.querySelector(".nam-mapstage"); 400 const backBtn = stage.querySelector("[data-nam-back]"); 401 if (!splash || !mapstage) return; 402 403 let inMap = false; 404 let inFlight = false; 405 const supportsWAAPI = typeof splash.animate === "function"; 406 407 const showMap = () => { 408 if (inMap || inFlight) return; 409 inFlight = true; 410 splash.style.pointerEvents = "none"; 411 const opts = { duration: 900, easing: "ease-in-out", fill: "forwards" }; 412 if (supportsWAAPI) { 413 splash.animate([{ opacity: 1 }, { opacity: 0 }], opts).onfinish = () => { 414 splash.style.display = "none"; 415 }; 416 mapstage.style.display = ""; 417 mapstage.animate( 418 [{ opacity: 0 }, { opacity: 1 }], 419 Object.assign({}, opts, { delay: 200 }) 420 ).onfinish = () => { 421 mapstage.classList.add("is-visible"); 422 mapstage.setAttribute("aria-hidden", "false"); 423 inMap = true; 424 inFlight = false; 425 window.dispatchEvent(new Event("nam-freight-map:reveal")); 426 }; 427 } else { 428 splash.style.display = "none"; 429 mapstage.style.opacity = "1"; 430 mapstage.classList.add("is-visible"); 431 mapstage.setAttribute("aria-hidden", "false"); 432 inMap = true; 433 inFlight = false; 434 window.dispatchEvent(new Event("nam-freight-map:reveal")); 435 } 436 }; 437 438 const showSplash = () => { 439 if (!inMap || inFlight) return; 440 inFlight = true; 441 const opts = { duration: 700, easing: "ease-in-out", fill: "forwards" }; 442 if (supportsWAAPI) { 443 mapstage.animate([{ opacity: 1 }, { opacity: 0 }], opts).onfinish = () => { 444 mapstage.classList.remove("is-visible"); 445 mapstage.setAttribute("aria-hidden", "true"); 446 mapstage.style.opacity = "0"; 447 }; 448 splash.style.display = ""; 449 splash.animate( 450 [{ opacity: 0 }, { opacity: 1 }], 451 Object.assign({}, opts, { delay: 150 }) 452 ).onfinish = () => { 453 splash.style.pointerEvents = ""; 454 inMap = false; 455 inFlight = false; 456 }; 457 } else { 458 mapstage.style.opacity = "0"; 459 mapstage.classList.remove("is-visible"); 460 mapstage.setAttribute("aria-hidden", "true"); 461 splash.style.display = ""; 462 splash.style.pointerEvents = ""; 463 inMap = false; 464 inFlight = false; 465 } 466 }; 467 468 splash.addEventListener("click", showMap); 469 splash.addEventListener( 470 "touchend", 471 (e) => { e.preventDefault(); showMap(); }, 472 { passive: false } 473 ); 474 splash.addEventListener("keydown", (e) => { 475 if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") { 476 e.preventDefault(); 477 showMap(); 478 } 479 }); 480 481 if (backBtn) { 482 backBtn.addEventListener("click", (e) => { e.preventDefault(); showSplash(); }); 483 backBtn.addEventListener( 484 "touchend", 485 (e) => { e.preventDefault(); showSplash(); }, 486 { passive: false } 487 ); 488 } 489 }); 490 } 491 initSplashStages(); 492 493 const mapEls = document.querySelectorAll(".nam-map"); 494 if (!mapEls.length) return; 495 496 mapEls.forEach((mapEl) => { 497 if (!mapEl.id) return; 498 499 let suppressRegionHover = false; 500 501 const map = new mapboxgl.Map({ 502 container: mapEl.id, 503 style: MAPBOX_STYLE, 504 projection: "mercator", 505 cooperativeGestures: true, 506 maxBounds: BOUNDS, 507 }); 508 509 // Allow panning + zoom; disable rotation 510 map.dragPan.enable(); 511 map.scrollZoom.enable(); 512 map.doubleClickZoom.enable(); 513 map.boxZoom.enable(); 514 map.keyboard.enable(); 515 map.touchZoomRotate.enable(); 516 map.touchZoomRotate.disableRotation(); 517 518 // Hover popup for regions (no close button) 519 const regionPopup = new mapboxgl.Popup({ 520 closeButton: false, 521 closeOnClick: false, 522 maxWidth: "300px", 523 }); 524 525 // Shared click popup for hubs/bottlenecks: 526 // closes when clicking anywhere else on the map, but not when clicking inside popup. 527 const clickPopup = new mapboxgl.Popup({ 528 closeButton: true, 529 closeOnClick: true, 530 maxWidth: "340px", 531 }); 532 533 clickPopup.on("open", () => { 534 const el = clickPopup.getElement(); 535 if (el) { 536 el.addEventListener("click", (evt) => { 537 evt.stopPropagation(); 538 }); 539 } 540 }); 541 542 let hoveredRegionId = null; 543 let regionsSource = null; 544 let regionsSourceLayer = null; 545 546 function clearRegionHover() { 547 regionPopup.remove(); 548 549 if (hoveredRegionId !== null && regionsSource && regionsSourceLayer) { 550 try { 551 map.setFeatureState( 552 { source: regionsSource, sourceLayer: regionsSourceLayer, id: hoveredRegionId }, 553 { hover: false } 554 ); 555 } catch (e) {} 556 } 557 hoveredRegionId = null; 558 } 559 560 // When this map sits inside the splash shortcode, the container starts 561 // with opacity:0 but is laid out at full size, so the canvas measures 562 // correctly. Still call resize() once revealed as a safety. 563 const onReveal = () => {
564 try { 565 map.resize(); 566 map.fitBounds(BOUNDS, { padding: 15, animate: false }); 567 } catch (e) {} 568 }; 569 window.addEventListener("nam-freight-map:reveal", onReveal); 570 571 map.on("load", () => { 572 // Fit to your bounds and keep view constrained 573 map.fitBounds(BOUNDS, { padding: 15, animate: false }); 574 map.resize(); 575 576 // Capture source/source-layer for feature-state on regions 577 const regLayer = map.getLayer(REGIONS_LAYER); 578 if (regLayer) { 579 regionsSource = regLayer.source; 580 regionsSourceLayer = regLayer["source-layer"] || null; 581 } 582 583 // Ensure hubs/bottlenecks are above shading layers so they can be clicked 584 const bringToTop = (layerId) => { 585 if (map.getLayer(layerId)) { 586 try { 587 map.moveLayer(layerId); 588 } catch (e) {} 589 } 590 }; 591 bringToTop(HUBS_LAYER); 592 bringToTop(BOTTLENECKS_LAYER); 593 594 // Hover outline: make shading-copy visible only for hovered region 595 if (map.getLayer(REGIONS_OUTLINE_LAYER) && regionsSource && regionsSourceLayer) { 596 // Prefer line styling (width 1px) 597 try { 598 map.setPaintProperty(REGIONS_OUTLINE_LAYER, "line-opacity", [ 599 "case", 600 ["boolean", ["feature-state", "hover"], false], 601 1, 602 0, 603 ]); 604 map.setPaintProperty(REGIONS_OUTLINE_LAYER, "line-width", [ 605 "case", 606 ["boolean", ["feature-state", "hover"], false], 607 1, 608 0, 609 ]); 610 } catch (e) { 611 // If shading-copy is a fill layer in the style, fallback to fill opacity 612 try { 613 map.setPaintProperty(REGIONS_OUTLINE_LAYER, "fill-opacity", [ 614 "case", 615 ["boolean", ["feature-state", "hover"], false], 616 1, 617 0, 618 ]); 619 } catch (e2) {} 620 } 621 } 622 623 /* ========================= 624 REGION HOVER (disabled when over hubs/bottlenecks) 625 ========================= */ 626 map.on("mousemove", REGIONS_LAYER, (e) => { 627 if (suppressRegionHover) return; 628 if (!e.features || !e.features.length) return; 629 if (!regionsSource || !regionsSourceLayer) return; 630 631 const f = e.features[0]; 632 633 // Clear previous hover state 634 if (hoveredRegionId !== null) { 635 try { 636 map.setFeatureState( 637 { source: regionsSource, sourceLayer: regionsSourceLayer, id: hoveredRegionId }, 638 { hover: false } 639 ); 640 } catch (err) {} 641 } 642 643 hoveredRegionId = f.id; 644 645 if (hoveredRegionId !== null && hoveredRegionId !== undefined) { 646 try { 647 map.setFeatureState( 648 { source: regionsSource, sourceLayer: regionsSourceLayer, id: hoveredRegionId }, 649 { hover: true } 650 ); 651 } catch (err) {} 652 } 653 654 const p = f.properties || {}; 655 regionPopup 656 .setLngLat(e.lngLat) 657 .setHTML( 658 `<strong>${esc(p.faf5_region_name)}</strong><br>` + 659 `Manufacturing cost of congestion: $${esc(format1(p.Mfg_Cost_Millions))}M` 660 ) 661 .addTo(map); 662 }); 663 664 map.on("mouseleave", REGIONS_LAYER, () => { 665 if (suppressRegionHover) return; 666 clearRegionHover(); 667 }); 668 669 /* ========================= 670 SUPPRESS REGION HOVER when over hubs/bottlenecks 671 ========================= */ 672 function suppressOnEnter() { 673 suppressRegionHover = true; 674 clearRegionHover(); 675 } 676 function unsuppressOnLeave() { 677 suppressRegionHover = false; 678 } 679 680 map.on("mouseenter", HUBS_LAYER, suppressOnEnter); 681 map.on("mouseleave", HUBS_LAYER, unsuppressOnLeave); 682 map.on("mouseenter", BOTTLENECKS_LAYER, suppressOnEnter); 683 map.on("mouseleave", BOTTLENECKS_LAYER, unsuppressOnLeave); 684 685 /* ========================= 686 CLICKABLE: HUBS 687 ========================= */ 688 map.on("click", HUBS_LAYER, (e) => { 689 if (!e.features || !e.features.length) return; 690 const p = e.features[0].properties || {}; 691 const title = `${p.city} ${p.reason} (${p.state})`; 692 693 clickPopup 694 .setLngLat(e.lngLat) 695 .setHTML(`<div class="nam-dialog"><strong>${esc(title)}</strong></div>`) 696 .addTo(map); 697 }); 698 699 /* ========================= 700 CLICKABLE: BOTTLENECKS (red header) 701 ========================= */ 702 map.on("click", BOTTLENECKS_LAYER, (e) => { 703 if (!e.features || !e.features.length) return; 704 const p = e.features[0].properties || {}; 705 706 clickPopup 707 .setLngLat(e.lngLat) 708 .setHTML( 709 `<div class="nam-dialog"> 710 <div class="nam-dialog__header">BOTTLENECK</div> 711 <strong>${esc(p.descriptive_name)}</strong><br> 712 Hours delayed: ${esc(format1(p.hours_delayed))} 713 </div>` 714 ) 715 .addTo(map); 716 }); 717 718 /* ========================= 719 CURSORS 720 ========================= */ 721 // Regions: hover only, not clickable 722 map.on("mouseenter", REGIONS_LAYER, () => { 723 map.getCanvas().style.cursor = suppressRegionHover ? "" : "default"; 724 }); 725 map.on("mouseleave", REGIONS_LAYER, () => { 726 map.getCanvas().style.cursor = ""; 727 }); 728 729 // Hubs + bottlenecks: pointer cursor
730 [HUBS_LAYER, BOTTLENECKS_LAYER].forEach((layerId) => { 731 map.on("mouseenter", layerId, () => (map.getCanvas().style.cursor = "pointer")); 732 map.on("mouseleave", layerId, () => (map.getCanvas().style.cursor = "")); 733 }); 734 735 /* ========================= 736 HOME / RESET CONTROL 737 ========================= */ 738 const HomeControl = function () {}; 739 HomeControl.prototype.onAdd = function (m) { 740 const container = document.createElement("div"); 741 container.className = "mapboxgl-ctrl mapboxgl-ctrl-group nam-home-ctrl"; 742 const button = document.createElement("button"); 743 button.type = "button"; 744 button.className = "mapboxgl-ctrl-icon nam-home-btn"; 745 button.title = "Reset map view"; 746 button.setAttribute("aria-label", "Reset map view"); 747 button.innerHTML = 748 '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">' + 749 '<path fill="currentColor" d="M12 3.2 3 11h2v9h5v-6h4v6h5v-9h2L12 3.2z"/></svg>'; 750 button.addEventListener("click", () => { 751 const wrap = mapEl.closest(".nam-freight-wrap"); 752 const inp = wrap && wrap.querySelector(".nam-address-search"); 753 if (inp) { 754 try { 755 if ("value" in inp) inp.value = ""; 756 } catch (e) {} 757 const innerInput = 758 inp.tagName && inp.tagName.toLowerCase().startsWith("gmp-") 759 ? inp.querySelector && inp.querySelector("input") 760 : null; 761 if (innerInput) { 762 try { innerInput.value = ""; } catch (e) {} 763 } 764 } 765 const msg = wrap && wrap.querySelector(".nam-search-msg"); 766 if (msg) msg.textContent = ""; 767 if (m.getLayer(ALL_BOTTLENECKS_LAYER)) { 768 try { 769 m.setLayoutProperty(ALL_BOTTLENECKS_LAYER, "visibility", "none"); 770 m.setFilter(ALL_BOTTLENECKS_LAYER, ["==", ["get", "county_fips"], "__none__"]); 771 } catch (e) {} 772 } 773 // Close any open popups 774 const openPopups = document.querySelectorAll(".mapboxgl-popup"); 775 openPopups.forEach((el) => { 776 const closeBtn = el.querySelector(".mapboxgl-popup-close-button"); 777 if (closeBtn) closeBtn.click(); 778 else el.remove(); 779 }); 780 m.fitBounds(BOUNDS, { padding: 15, duration: 500 }); 781 }); 782 container.appendChild(button); 783 this._container = container; 784 return container; 785 }; 786 HomeControl.prototype.onRemove = function () { 787 if (this._container && this._container.parentNode) { 788 this._container.parentNode.removeChild(this._container); 789 } 790 }; 791 try { map.addControl(new HomeControl(), "top-right"); } catch (e) {} 792 793 /* ========================= 794 ADDRESS SEARCH 795 ========================= */ 796 const wrap = mapEl.closest(".nam-freight-wrap"); 797 // Defer briefly so the new `all-bottlenecks` source-layer tiles have a 798 // chance to load before the user's first query. 799 map.once("idle", () => initAddressSearch(map, wrap)); 800 }); 801 }); 802});
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.