PageSourceSearch

https://nam.org/wp-content/plugins/nam-freight-map-clean/assets/js/map.js?ver=1790238659

js nam.org collected 2026-09-24 08:32:23 UTC 27,457 bytes, 802 lines download raw bytes

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, "&lt;")
51      .replace(/>/g, "&gt;")
52      .replace(/"/g, "&quot;")
53      .replace(/'/g, "&#039;");
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.