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.