PageSourceSearch

https://mountaincabinsutah.com/wp-content/plugins/homerunner-toolkit/assets/js/map-stacking.js?ver=3.309.18

js mountaincabinsutah.com collected 2026-10-10 02:01:42 UTC 29,410 bytes, 815 lines download raw bytes

1/**
2 * Map Stacking — HomeRunner Toolkit
3 *
4 * Wraps $.fn.homelocalExplorerMap to detect co-located listings
5 * and render stacked markers with spiderfy expand on click.
6 *
7 * Load order: homerunner-explorer-map -> hrtk-map-stacking -> homerunner-explorer
8 */
9(function ($) {
10  // Leaflet-only for now
11  if (typeof L === "undefined") return;
12
13  // ---------------------------------------------------------------------------
14  // Marker registry — intercepts L.Marker.prototype.addTo to capture
15  // OverlayMarker instances (they have id_ set) and the Leaflet map reference.
16  // ---------------------------------------------------------------------------
17  let _markerRegistry = {};
18  let _leafletMap = null;
19  let _suppressRegistry = false;
20  let _overlayPatched = false;
21
22  const _origAddTo = L.Marker.prototype.addTo;
23  L.Marker.prototype.addTo = function (map) {
24    const result = _origAddTo.call(this, map);
25    if (!_suppressRegistry && this.id_ !== undefined) {
26      _markerRegistry[this.id_] = this;
27      _leafletMap = map;
28      patchOverlayOnRemove(this);
29    }
30    return result;
31  };
32
33  // ---------------------------------------------------------------------------
34  // OverlayMarker.onRemove completion patch.
35  // The core OverlayMarker (explorer-map-leaflet.js) overrides L.Marker.onRemove
36  // and never calls its Leaflet super, so removeLayer leaves the marker's DOM icon
37  // (and shadow) in the pane and never deregisters the zoomanim -> _animateZoom
38  // listener. Our cluster path removes every marker and re-renders it via
39  // markercluster, so each orphaned core icon is left stranded on top of the
40  // cluster output (duplicate pins that don't track zoom). OverlayMarker is private
41  // to the plugin's closure, but it's a single L.Marker subclass — we reach its
42  // prototype through any live instance and wrap onRemove to also run the super,
43  // which finishes the teardown for every removal path. Idempotent (one-shot flag).
44  function patchOverlayOnRemove(marker) {
45    if (_overlayPatched || !marker) return;
46    const proto = Object.getPrototypeOf(marker); // OverlayMarker.prototype
47    if (!proto || proto === L.Marker.prototype) return;
48    if (!Object.prototype.hasOwnProperty.call(proto, "onRemove")) return;
49    const overrideOnRemove = proto.onRemove;
50    proto.onRemove = function (map) {
51      const result = overrideOnRemove.apply(this, arguments);
52      // The override nulls this._map (and its own this.map_), so prefer the map
53      // Leaflet passes in; only call super when we actually have a map to off().
54      const m = map || this._map;
55      if (m) {
56        try {
57          L.Marker.prototype.onRemove.call(this, m);
58        } catch (e) {
59          /* core may have already torn parts down; ignore */
60        }
61      }
62      return result;
63    };
64    _overlayPatched = true;
65  }
66
67  // ---------------------------------------------------------------------------
68  // Zoom-animation guard.
69  // The core OverlayMarker (explorer-map-leaflet.js) overrides L.Marker.onRemove
70  // WITHOUT calling its Leaflet super, so the 'zoomanim' -> _animateZoom listener
71  // that L.Marker.onAdd registers is never deregistered on removal. After removal
72  // marker._map is null, but the orphaned listener stays on the map; the next zoom
73  // fires it and `this._map._latLngToNewLayerPoint(...)` throws on null, aborting
74  // Leaflet's zoomanim fire loop — every marker queued after the throw never gets
75  // repositioned and "sticks" on screen during the zoom. Our stacking/clustering
76  // churns markers (remove + re-add) far more than core did, which is why it shows
77  // up now. Bail when the marker has no map: the dead listeners become harmless
78  // no-ops and every live marker tracks the zoom. (Upstream fix belongs in the
79  // plugin's OverlayMarker.onRemove — it should call L.Marker.prototype.onRemove.)
80  const _origMarkerAnimateZoom = L.Marker.prototype._animateZoom;
81  if (typeof _origMarkerAnimateZoom === "function") {
82    L.Marker.prototype._animateZoom = function (opt) {
83      if (!this._map) return;
84      return _origMarkerAnimateZoom.call(this, opt);
85    };
86  }
87
88  // ---------------------------------------------------------------------------
89  // Plugin wrapper
90  // ---------------------------------------------------------------------------
91  const originalPlugin = $.fn.homelocalExplorerMap;
92  if (!originalPlugin) return;
93
94  $.fn.homelocalExplorerMap = function (options, explorerSettings) {
95    const app = originalPlugin.call(this, options, explorerSettings);
96    const opts = $.extend({}, options);
97
98    // ---- State ----
99    let properties = [];
100    let propertyMap = {}; // id -> property (O(1) lookup)
101    let stackGroups = {}; // "lat,lng" -> [propertyId, ...]
102    let stackMarkers = {}; // "lat,lng" -> L.Marker (stacked badge)
103    let markerLookup = {}; // propertyId -> "lat,lng" stack key
104    let spiderfiedKey = null;
105    let spiderLegs = [];
106    let spiderMarkers = [];
107    let centerMarker = null;
108    let spiderPopup = null;
109    let _cachedGroups = null;
110
111    const SPIDER_RADIUS_PX = 70;
112    const MAX_CIRCLE_COUNT = 8;
113    let _spiderPaneReady = false;
114
115    // ---- Leaflet clustering (markercluster) ----
116    // When the explorer has marker_cluster enabled and Leaflet.markercluster is
117    // loaded, route the core markers into an L.markerClusterGroup (density
118    // clustering + the library's native spiderfy for co-located pins) instead of
119    // the custom stacked-badge path. Cluster-primary, like the Google variant.
120    const clusterMode =
121      opts.marker_cluster === "1" &&
122      typeof L.markerClusterGroup === "function";
123    let _clusterGroup = null;
124
125    const CLUSTER_ICONS = [
126      opts.marker_cluster_icon1,
127      opts.marker_cluster_icon2,
128      opts.marker_cluster_icon3,
129      opts.marker_cluster_icon4,
130      opts.marker_cluster_icon5,
131    ];
132    const CLUSTER_SIZES = [53, 56, 66, 78, 90]; // mirror the Google clusterer
133
134    function clusterTier(count) {
135      if (count < 10) return 0;
136      if (count < 25) return 1;
137      if (count < 50) return 2;
138      if (count < 100) return 3;
139      return 4;
140    }
141
142    function ensureClusterGroup() {
143      if (_clusterGroup || !_leafletMap) return;
144      _clusterGroup = L.markerClusterGroup({
145        showCoverageOnHover: false,
146        maxClusterRadius: 60,
147        spiderfyOnMaxZoom: true,
148        iconCreateFunction: function (cluster) {
149          const count = cluster.getChildCount();
150          const tier = clusterTier(count);
151          const size = CLUSTER_SIZES[tier];
152
153          // Two mutually exclusive paint paths.
154          //
155          // Bundled marker: PHP decided this site has no cluster icons of its
156          // own, so we tag the tier and CSS paints one scalable SVG as a mask
157          // tinted by --hrtk-cluster-N. No image URL is emitted, which is what
158          // keeps the settings colours authoritative.
159          //
160          // Site-supplied icons: emit their image exactly as before. The tier
161          // class is withheld so the mask rules never match and their artwork
162          // is never recoloured.
163          const bundled = !!(window.hrtkMapStacking && window.hrtkMapStacking.bundledMarker);
164          const url = bundled ? "" : CLUSTER_ICONS[tier] || CLUSTER_ICONS[0];
165          const bg = url ? "background-image:url('" + url + "');" : "";
166          const tierClass = bundled
167            ? " hrtk-leaflet-cluster--t" + (tier + 1)
168            : "";
169
170          return L.divIcon({
171            html:
172              '<div class="hrtk-leaflet-cluster' +
173              tierClass +
174              '" style="' +
175              bg +
176              "width:" +
177              size +
178              "px;height:" +
179              size +
180              'px;"><span>' +
181              count +
182              "</span></div>",
183            className: "hrtk-leaflet-cluster-wrap",
184            iconSize: [size, size],
185          });
186        },
187      });
188      // Click an individual (declustered) pin -> open its property-card popup.
189      // The core OverlayMarker's own click/tooltip wiring is torn down when the
190      // marker is moved into the cluster group (override onRemove strips its
191      // listeners + tooltip), so we open the infowindow at the group level. Leaf
192      // marker clicks bubble to the group as 'click' (e.layer = the marker);
193      // cluster-bubble clicks fire 'clusterclick' and keep the default zoom-in.
194      _clusterGroup.on("click", function (e) {
195        if (e.layer && e.layer.id_ !== undefined) {
196          showClusterMarkerPopup(e.layer);
197        }
198      });
199      _leafletMap.addLayer(_clusterGroup);
200    }
201
202    function showClusterMarkerPopup(marker) {
203      if (!marker || marker.id_ === undefined || !_leafletMap) return;
204      closeSpiderTooltip();
205      const content = app.getPopupContent(marker.id_);
206      if (!content) return;
207      spiderPopup = L.popup({
208        className: "hrtk-spider-popup",
209        closeButton: true,
210        offset: L.point(0, -15),
211        maxWidth: 320,
212      })
213        .setLatLng(marker.getLatLng())
214        .setContent('<div class="hfse-map-tooltip-content">' + content + "</div>")
215        .openOn(_leafletMap);
216    }
217
218    function ensureSpiderPane() {
219      if (_spiderPaneReady || !_leafletMap) return;
220      _leafletMap.createPane("hrtkSpiderPane");
221      _leafletMap.getPane("hrtkSpiderPane").style.zIndex = 650;
222      _spiderPaneReady = true;
223    }
224
225    // =========================================================================
226    // Helpers
227    // =========================================================================
228
229    function groupByLocation(props) {
230      const groups = {};
231      props.forEach((p) => {
232        const geo = p.acf && p.acf.geolocation;
233        if (!geo || !geo.lat || !geo.lng) return;
234        const key = `${geo.lat},${geo.lng}`;
235        if (!groups[key]) groups[key] = [];
236        groups[key].push(p.id);
237      });
238      return groups;
239    }
240
241    function resolvePriceField(property) {
242      const acf = property.acf || {};
243      const currency = acf.currency;
244      const base = parseFloat(acf.base_daily_rate) || Infinity;
245      const avg = parseFloat(property.average_rent) || 0;
246      const low = parseFloat(property.lowest_rent) || 0;
247      const total = parseFloat(property.total_rent) || 0;
248
249      if (opts.full_price === "1" && app.isDateQuery()) {
250        return { value: total || base, currency: currency };
251      }
252      const mode = app.isDateQuery()
253        ? opts.with_dates_price
254        : opts.no_dates_price;
255      // Mirror core getBadgeHTML semantics: "none" (and any unrecognized
256      // mode) resolves to no price so the icon fallback renders instead.
257      switch (mode) {
258        case "average_price":
259          return { value: avg || base, currency: currency };
260        case "base_price":
261          return { value: base, currency: currency };
262        case "lowest_price":
263          return { value: low || base, currency: currency };
264        default:
265          return { value: Infinity, currency: currency };
266      }
267    }
268
269    function getPropertyPriceText(property) {
270      const r = resolvePriceField(property);
271      return r.value === Infinity ? "" : app.price(r.value, r.currency);
272    }
273
274    function getPriceRangeText(ids) {
275      let lowestVal = Infinity;
276      let highestVal = -Infinity;
277      let currency = null;
278
279      ids.forEach((id) => {
280        const prop = propertyMap[id];
281        if (!prop) return;
282        const r = resolvePriceField(prop);
283        if (!currency) currency = r.currency;
284        if (r.value < lowestVal) lowestVal = r.value;
285        if (r.value > highestVal) highestVal = r.value;
286      });
287
288      if (lowestVal === Infinity) return "";
289
290      const lowText = app.price(lowestVal, currency);
291      const highText = app.price(highestVal, currency);
292
293      if (lowestVal === highestVal || highestVal === -Infinity) {
294        return lowText;
295      }
296      return lowText + " – " + highText;
297    }
298
299    function parseStackKey(key) {
300      const parts = key.split(",");
301      return L.latLng(parseFloat(parts[0]), parseFloat(parts[1]));
302    }
303
304    // Spider pane options for markers that must render above all others
305    function spiderPaneOpts() {
306      ensureSpiderPane();
307      return { pane: "hrtkSpiderPane" };
308    }
309
310    // =========================================================================
311    // Stacked marker creation
312    // =========================================================================
313
314    // Badge markup depends on core plugin's hfse-map-badge class
315    function createStackedMarker(key, ids) {
316      const latLng = parseStackKey(key);
317      const priceRange = getPriceRangeText(ids);
318      const count = ids.length;
319
320      // No price (display mode "none"): count circle alone, no text label
321      const badge =
322        '<div class="hfse-map-badge hfse-map-badge-stacked' +
323        (priceRange ? "" : " hrtk-stack-count-only") +
324        '">' +
325        (priceRange
326          ? '<span class="hrtk-stack-price">' + priceRange + "</span>"
327          : "") +
328        '<span class="hrtk-stack-count">' +
329        count +
330        "</span>" +
331        "</div>";
332
333      _suppressRegistry = true;
334      const marker = L.marker(latLng, {
335        icon: L.divIcon({
336          html: badge,
337          className: "hrtk-stacked-marker-wrapper",
338          iconSize: [null, 36],
339          iconAnchor: [0, 18],
340        }),
341        interactive: true,
342        zIndexOffset: 500,
343      });
344
345      marker._stackKey = key;
346      marker._stackIds = ids;
347      marker.addTo(_leafletMap);
348      _suppressRegistry = false;
349
350      marker.on("click", () => {
351        expandSpiderfy(key);
352      });
353
354      marker.focus = function () {
355        if (this._icon) {
356          this._icon.classList.add("hfse-map-badge-parent-focus");
357        }
358      };
359      marker.blur = function () {
360        if (this._icon) {
361          this._icon.classList.remove("hfse-map-badge-parent-focus");
362        }
363      };
364
365      return marker;
366    }
367
368    // =========================================================================
369    // Spiderfy
370    // =========================================================================
371
372    function getSpiderPositions(center, count) {
373      if (!_leafletMap) return [];
374      const centerPt = _leafletMap.latLngToContainerPoint(center);
375
376      if (count > MAX_CIRCLE_COUNT) {
377        return Array.from({ length: count }, (_, i) => {
378          const angle = i * 0.618 * 2 * Math.PI;
379          const radius = SPIDER_RADIUS_PX + i * 10;
380          const x = centerPt.x + radius * Math.cos(angle);
381          const y = centerPt.y + radius * Math.sin(angle);
382          return _leafletMap.containerPointToLatLng(L.point(x, y));
383        });
384      }
385
386      return Array.from({ length: count }, (_, i) => {
387        const angle = (2 * Math.PI * i) / count - Math.PI / 2;
388        const x = centerPt.x + SPIDER_RADIUS_PX * Math.cos(angle);
389        const y = centerPt.y + SPIDER_RADIUS_PX * Math.sin(angle);
390        return _leafletMap.containerPointToLatLng(L.point(x, y));
391      });
392    }
393
394    function expandSpiderfy(key) {
395      if (spiderfiedKey === key) {
396        collapseSpiderfy();
397        return;
398      }
399      collapseSpiderfy();
400
401      const ids = stackGroups[key];
402      if (!ids || !ids.length) return;
403
404      const latLng = parseStackKey(key);
405      const positions = getSpiderPositions(latLng, ids.length);
406
407      // Remove the stacked marker from the map entirely
408      if (stackMarkers[key] && stackMarkers[key]._map) {
409        _leafletMap.removeLayer(stackMarkers[key]);
410      }
411
412      _suppressRegistry = true;
413
414      // Center dot — rendered in spider pane above all other markers
415      centerMarker = L.marker(latLng, $.extend({
416        icon: L.divIcon({
417          html: '<div class="hrtk-spider-center"></div>',
418          className: "hrtk-spider-center-wrapper",
419          iconSize: [16, 16],
420          iconAnchor: [8, 8],
421        }),
422        interactive: true,
423      }, spiderPaneOpts())).addTo(_leafletMap);
424
425      centerMarker.on("click", () => {
426        collapseSpiderfy();
427      });
428
429      // Create individual spider markers + connector legs
430      ids.forEach((id, i) => {
431        const prop = propertyMap[id];
432        if (!prop) return;
433
434        const priceText = getPropertyPriceText(prop);
435        const badgeHTML = app.getBadgeHTML(priceText);
436        const targetPos = positions[i];
437
438        const sMarker = L.marker(latLng, $.extend({
439          icon: L.divIcon({
440            html: badgeHTML,
441            className: "hrtk-spider-marker",
442            iconSize: [60, 40],
443            iconAnchor: [30, 20],
444          }),
445          interactive: true,
446        }, spiderPaneOpts())).addTo(_leafletMap);
447
448        sMarker._propertyId = id;
449
450        requestAnimationFrame(() => {
451          if (sMarker._icon) {
452            sMarker._icon.classList.add("hrtk-spider-animating");
453          }
454          sMarker.setLatLng(targetPos);
455          setTimeout(() => {
456            if (sMarker._icon) {
457              sMarker._icon.classList.remove("hrtk-spider-animating");
458            }
459          }, 350);
460        });
461
462        sMarker.on("click", () => {
463          showSpiderTooltip(sMarker, id);
464        });
465
466        sMarker.focus = function () {
467          if (this._icon) {
468            this._icon.classList.add("hrtk-spider-marker-highlight");
469          }
470        };
471        sMarker.blur = function () {
472          if (this._icon) {
473            this._icon.classList.remove("hrtk-spider-marker-highlight");
474          }
475        };
476
477        spiderMarkers.push(sMarker);
478
479        const leg = L.polyline([latLng, targetPos], $.extend({
480          weight: 1.5,
481          opacity: 0.4,
482          dashArray: "4 2",
483          className: "hrtk-spider-leg",
484        }, spiderPaneOpts())).addTo(_leafletMap);
485
486        spiderLegs.push(leg);
487      });
488
489      _suppressRegistry = false;
490      spiderfiedKey = key;
491
492      // Fade all other markers so the spider cluster stands out
493      var markerPane = _leafletMap.getPane("markerPane");
494      if (markerPane) markerPane.style.opacity = String((window.hrtkMapStacking && window.hrtkMapStacking.fadeOpacity) || 0.4);
495
496      _leafletMap.once("click", onMapClickWhileSpiderfied);
497      _leafletMap.on("zoomend", onZoomWhileSpiderfied);
498      _leafletMap.on("moveend", onMoveWhileSpiderfied);
499    }
500
501    function onMapClickWhileSpiderfied() {
502      collapseSpiderfy();
503    }
504
505    // Leaflet analog of the Google idle bounds-check: a centered pan (e.g. from
506    // focusMarker) fires moveend without zoomend, so collapse when the spider
507    // anchor scrolls out of view rather than clinging off-screen with the dim.
508    function onMoveWhileSpiderfied() {
509      if (!spiderfiedKey) return;
510      if (!_leafletMap.getBounds().contains(parseStackKey(spiderfiedKey))) {
511        collapseSpiderfy();
512      }
513    }
514
515    function onZoomWhileSpiderfied() {
516      if (!spiderfiedKey) return;
517      // If the zoom pushed the spider anchor out of the viewport, collapse
518      // instead of recomputing the ring (mirrors the Google bounds check).
519      if (!_leafletMap.getBounds().contains(parseStackKey(spiderfiedKey))) {
520        collapseSpiderfy();
521        return;
522      }
523      const ids = stackGroups[spiderfiedKey];
524      if (!ids) return;
525
526      const latLng = parseStackKey(spiderfiedKey);
527      const positions = getSpiderPositions(latLng, ids.length);
528
529      spiderMarkers.forEach((m, i) => {
530        if (positions[i]) m.setLatLng(positions[i]);
531      });
532
533      spiderLegs.forEach((leg, i) => {
534        if (positions[i]) leg.setLatLngs([latLng, positions[i]]);
535      });
536    }
537
538    function collapseSpiderfy() {
539      if (!spiderfiedKey) return;
540      const key = spiderfiedKey;
541
542      spiderMarkers.forEach((m) => {
543        m.off();
544        if (m._map) _leafletMap.removeLayer(m);
545      });
546      spiderMarkers = [];
547
548      spiderLegs.forEach((l) => {
549        if (l._map) _leafletMap.removeLayer(l);
550      });
551      spiderLegs = [];
552
553      if (centerMarker) {
554        centerMarker.off();
555        if (centerMarker._map) _leafletMap.removeLayer(centerMarker);
556        centerMarker = null;
557      }
558
559      closeSpiderTooltip();
560
561      // Restore other markers
562      var markerPane = _leafletMap.getPane("markerPane");
563      if (markerPane) markerPane.style.opacity = "";
564
565      // Re-add the stacked marker to the map
566      _suppressRegistry = true;
567      if (stackMarkers[key] && !stackMarkers[key]._map) {
568        stackMarkers[key].addTo(_leafletMap);
569      }
570      _suppressRegistry = false;
571
572      _leafletMap.off("zoomend", onZoomWhileSpiderfied);
573      _leafletMap.off("moveend", onMoveWhileSpiderfied);
574      _leafletMap.off("click", onMapClickWhileSpiderfied);
575      spiderfiedKey = null;
576    }
577
578    // =========================================================================
579    // Spider tooltip (Leaflet popup)
580    // =========================================================================
581
582    function showSpiderTooltip(marker, propertyId) {
583      closeSpiderTooltip();
584      const content = app.getPopupContent(propertyId);
585      if (!content) return;
586
587      spiderPopup = L.popup({
588        className: "hrtk-spider-popup",
589        closeButton: true,
590        offset: L.point(0, -15),
591        maxWidth: 320,
592      })
593        .setLatLng(marker.getLatLng())
594        .setContent(
595          '<div class="hfse-map-tooltip-content">' + content + "</div>",
596        )
597        .openOn(_leafletMap);
598    }
599
600    function closeSpiderTooltip() {
601      if (spiderPopup && _leafletMap) {
602        _leafletMap.closePopup(spiderPopup);
603        spiderPopup = null;
604      }
605    }
606
607    // =========================================================================
608    // Cleanup helpers
609    // =========================================================================
610
611    function cleanupStackMarkers() {
612      Object.values(stackMarkers).forEach((m) => {
613        if (m) {
614          m.off();
615          if (m._map) _leafletMap.removeLayer(m);
616        }
617      });
618      stackGroups = {};
619      stackMarkers = {};
620      markerLookup = {};
621    }
622
623    // =========================================================================
624    // Wrap app methods
625    // =========================================================================
626
627    const origSetProperties = app.setProperties;
628    app.setProperties = function (props) {
629      properties = props || [];
630      propertyMap = {};
631      properties.forEach((p) => {
632        propertyMap[p.id] = p;
633      });
634      _cachedGroups = null;
635      return origSetProperties.call(app, props);
636    };
637
638    const origUpdateMarkers = app.updateMarkers;
639    app.updateMarkers = function () {
640      collapseSpiderfy();
641      cleanupStackMarkers();
642      _markerRegistry = {};
643      if (_clusterGroup) _clusterGroup.clearLayers();
644
645      origUpdateMarkers.call(app);
646
647      if (!_leafletMap || !properties.length) return;
648
649      // Cluster-primary: hand every core marker to the cluster group (density
650      // clustering + the library's native spiderfy for co-located pins) and skip
651      // the custom stacked-badge path. Markers are added to the map by their
652      // constructor, so move them off the map and into the cluster.
653      if (clusterMode) {
654        ensureClusterGroup();
655        Object.keys(_markerRegistry).forEach((id) => {
656          const m = _markerRegistry[id];
657          if (m._map) _leafletMap.removeLayer(m);
658          _clusterGroup.addLayer(m);
659        });
660        return;
661      }
662
663      var groups = _cachedGroups || (_cachedGroups = groupByLocation(properties));
664
665      Object.entries(groups).forEach(([key, ids]) => {
666        if (ids.length < 2) return;
667
668        stackGroups[key] = ids;
669
670        ids.forEach((id) => {
671          const marker = _markerRegistry[id];
672          if (marker) {
673            if (marker.unbindTooltip) marker.unbindTooltip();
674            if (marker._map) marker._map.removeLayer(marker);
675          }
676          markerLookup[id] = key;
677        });
678
679        stackMarkers[key] = createStackedMarker(key, ids);
680      });
681    };
682
683    function applyMarkerState(id, method) {
684      const stackKey = markerLookup[id];
685      if (stackKey && stackMarkers[stackKey]) {
686        stackMarkers[stackKey][method]();
687        if (spiderfiedKey === stackKey) {
688          spiderMarkers.forEach((m) => {
689            if (m._propertyId === id) m[method]();
690          });
691        }
692        return true;
693      }
694      return false;
695    }
696
697    // Pan the hovered pin to the center of the map viewport. The explorer map is
698    // side-by-side with the grid (not occluded), so the viewport is the visible
699    // space. Always recenters on hover. (Leaflet mirror of the Google version.)
700    function centerOnMarker(id) {
701      if (!_leafletMap) return;
702      // With search-as-move active, a programmatic pan fires Leaflet moveend,
703      // which core treats as a boundary change: performFilter re-renders the
704      // cards, the fresh card under the stationary cursor re-fires mouseenter
705      // (map-extras' always-on hover handler), and the pan repeats — an
706      // infinite reload loop. Core disables hover-pan in this state for the
707      // same reason; keep the highlight, drop the pan. Google's twin is safe:
708      // dragend/zoom_changed don't fire on programmatic pans.
709      const samCheckbox = document.getElementById("hr-map-search-checkbox");
710      if (samCheckbox && samCheckbox.checked) return;
711      const prop = propertyMap[id];
712      const geo = prop && prop.acf && prop.acf.geolocation;
713      if (!geo || !geo.lat || !geo.lng) return;
714      _leafletMap.panTo([parseFloat(geo.lat), parseFloat(geo.lng)]);
715    }
716
717    // Cluster-mode hover highlight: invert the hovered card's pin (matching core's
718    // .hfse-map-badge-parent-focus active state). Only a declustered, individually
719    // shown marker has its own pin to invert; while it's folded into a cluster
720    // bubble there is no per-listing pin, so we just center (handled by caller).
721    let _clusterFocusEl = null;
722    function clusterClearHighlight() {
723      if (_clusterFocusEl) {
724        _clusterFocusEl.classList.remove("hfse-map-badge-parent-focus");
725        _clusterFocusEl = null;
726      }
727    }
728    function clusterHighlight(id) {
729      clusterClearHighlight();
730      const m = _markerRegistry[id];
731      if (!m || !_clusterGroup) return;
732      let vis = m;
733      if (_clusterGroup.getVisibleParent) {
734        try {
735          vis = _clusterGroup.getVisibleParent(m) || m;
736        } catch (e) {
737          vis = m;
738        }
739      }
740      // vis === m means the pin is shown individually; otherwise it's clustered.
741      if (vis === m && m._icon) {
742        m._icon.classList.add("hfse-map-badge-parent-focus");
743        _clusterFocusEl = m._icon;
744      }
745    }
746
747    const origFocusMarker = app.focusMarker;
748    app.focusMarker = function (id) {
749      // In cluster mode there's no stacked-badge/spider path; center on the
750      // hovered card's location and invert its pin when it's individually shown.
751      if (clusterMode) {
752        centerOnMarker(id);
753        clusterHighlight(id);
754        return;
755      }
756
757      // If a spider is open and the hovered card isn't part of that stack,
758      // collapse it before panning elsewhere — otherwise the centered pan would
759      // carry the dim + spider off-screen and leave the new view faded.
760      if (spiderfiedKey && markerLookup[id] !== spiderfiedKey) {
761        collapseSpiderfy();
762      }
763
764      // Core's OverlayMarker.focus() only nudges the pin toward the edge of view
765      // (and only when it's outside a 10% inner box). We want the hovered pin
766      // centered, so suppress core's pan during the focus (keeping its badge
767      // highlight) and issue one clean centered pan afterwards. Leaflet panTo is
768      // a prototype method on the map instance, so we shadow it with an own
769      // property and restore by delete (or re-assign if one already existed).
770      const hadOwnPanTo =
771        _leafletMap &&
772        Object.prototype.hasOwnProperty.call(_leafletMap, "panTo");
773      const realPanTo = _leafletMap ? _leafletMap.panTo : null;
774      if (_leafletMap) _leafletMap.panTo = function () {};
775      try {
776        if (!applyMarkerState(id, "focus")) origFocusMarker.call(app, id);
777      } finally {
778        if (_leafletMap) {
779          if (hadOwnPanTo) _leafletMap.panTo = realPanTo;
780          else delete _leafletMap.panTo;
781        }
782      }
783      centerOnMarker(id);
784    };
785
786    const origBlurMarker = app.blurMarker;
787    app.blurMarker = function (id) {
788      if (clusterMode) {
789        clusterClearHighlight();
790        return;
791      }
792      if (!applyMarkerState(id, "blur")) origBlurMarker.call(app, id);
793    };
794
795    // Re-enable hover-highlight when map search is active.
796    // Core's mouseenter/mouseleave handlers bail when mapSearchActive=true,
797    // so focusMarker/blurMarker never get called. We add our own handlers
798    // that always fire, giving us direct access to the patched app methods.
799
800    // Accessor for sibling toolkit modules (Map All Pins feeds extra markers
801    // into the same cluster group). Values are closure-held and only settle
802    // after the first marker render, so callers read them lazily per use.
803    app.hrtkStacking = function () {
804      if (clusterMode) ensureClusterGroup();
805      return {
806        map: _leafletMap,
807        clusterMode: clusterMode,
808        clusterGroup: _clusterGroup,
809        registry: _markerRegistry,
810      };
811    };
812
813    return app;
814  };
815})(jQuery);

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.