1const LeafletMapPolygon = () => { 2 let obj = {}; 3 4 obj.saleRent = 'for_sale'; 5 // Private state 6 let drawingActive = false; 7 let currentPolygon = null; 8 let coordsString = ""; 9 let polygon = null; 10 let bounds = null; 11 let polygonDataCount = 0; 12 let closeMarkerEnabled = false; 13 let polygonLoadedOnTab = false; 14 let drawnItems = null; 15 let drawControl = null; 16 let currentVertexCount = 0; 17 let polygonClosedByUser = false; 18 const areaRingsCache = {}; 19 let lastPolygonLatLngs = null; 20 // The exact area_of_interest string the removed zone was searched with, so a restore 21 // re-runs that search rather than a re-serialised approximation of it. 22 let lastPolygonCoordsString = ""; 23 let zoneRemovedPending = false; 24 let zoneRemovedTimeout = null; 25 // Whether the "zone removed" toast is the one on screen. checkZoomForPolygon runs on 26 // every moveend - the pins landing after a reload included - and must not talk over it. 27 let zoneRemovedToastActive = false; 28 // The message currently on screen, so a repeat of the same one can be told apart 29 // from a change - the emphasis bounce is for the latter only. 30 let currentAlertMessage = null; 31 32 // DOM elements 33 let mapContainer = null; 34 let startBtn = null; 35 let startBtnIcon = null; 36 let startBtnText = null; 37 let polygonMessage = null; 38 39 // Constants 40 const MAX_POLYGON_POINTS = 10; 41 // FCS-400: the draw tool stays available until the visible map spans more 42 // than this distance, at which point the button is disabled and the 43 // "zoom in" message is shown. 44 const MAX_DRAW_DISTANCE_KM = 250; 45 // How long the "zone removed" toast keeps its restore CTA on an idle map. Panning or 46 // zooming replaces it sooner, through the "zoomend moveend" handler. 47 const ZONE_REMOVED_TOAST_MS = 3000; 48 // How long a visible-area map has to sit still before its search is re-run. The 49 // reload replaces the whole results section - the map container with it - so it 50 // rebuilds Leaflet from scratch and the map goes dead under the cursor while that 51 // happens. Long enough that a series of drags, or a few notches of wheel zoom, 52 // coalesces into one search after the user has actually stopped. 53 const VISIBLE_MAP_SEARCH_DEBOUNCE_MS = 800; 54 const icons = { 55 default: ` 56 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="display:block;"> 57 <path d="M4.50024 7C3.7981 10.2964 3.7981 13.7036 4.50024 17" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> 58 <path d="M7 19.5C10.2964 20.2021 13.7036 20.2021 17 19.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> 59 <path d="M20.7 17.5H18.3C17.8582 17.5 17.5 17.8582 17.5 18.3V20.7C17.5 21.1418 17.8582 21.5 18.3 21.5H20.7C21.1418 21.5 21.5 21.1418 21.5 20.7V18.3C21.5 17.8582 21.1418 17.5 20.7 17.5Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> 60 <path d="M5.7 17.5H3.3C2.85817 17.5 2.5 17.8582 2.5 18.3V20.7C2.5 21.1418 2.85817 21.5 3.3 21.5H5.7C6.14183 21.5 6.5 21.1418 6.5 20.7V18.3C6.5 17.8582 6.14183 17.5 5.7 17.5Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> 61 <path d="M5.7 2.5H3.3C2.85817 2.5 2.5 2.85817 2.5 3.3V5.7C2.5 6.14183 2.85817 6.5 3.3 6.5H5.7C6.14183 6.5 6.5 6.14183 6.5 5.7V3.3C6.5 2.85817 6.14183 2.5 5.7 2.5Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> 62 <path d="M18.076 3.78718L20.213 5.92426M11.2724 10.5908L10.5601 13.4402L13.4095 12.7279L21.3429 4.79446C21.6099 4.52728 21.76 4.16496 21.76 3.78718C21.76 3.40939 21.6099 3.04707 21.3429 2.7799L21.2203 2.65737C20.9531 2.39028 20.5908 2.24023 20.213 2.24023C19.8353 2.24023 19.4729 2.39028 19.2058 2.65737L11.2724 10.5908Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="square" stroke-linejoin="round"/> 63 </svg> 64 `, 65 cancel: ` 66 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="display:block;"> 67 <path d="M3.5 3.49988L20.4 20.4" stroke="white" stroke-linecap="square" stroke-linejoin="round"/> 68 <path d="M3.5 20.4994L20.4 3.59949" stroke="white" stroke-linecap="square" stroke-linejoin="round"/> 69 </svg> 70 `, 71 closePolygonIcon: `
72 <svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg" style="display:block;border-radius:50px;background-color:#FFFFFF;"> 73 <rect x="1" y="1" width="33" height="33" rx="16.5" fill="white"/> 74 <rect x="1" y="1" width="33" height="33" rx="16.5" stroke="#003C46" stroke-width="2"/> 75 <path d="M10.7386 10.7383L24.1818 24.1815" stroke="#003C46" stroke-width="1.59091" stroke-linecap="square" stroke-linejoin="round"/> 76 <path d="M10.7386 24.2605L24.1818 10.8174" stroke="#003C46" stroke-width="1.59091" stroke-linecap="square" stroke-linejoin="round"/> 77 </svg> 78 `, 79 }; 80 81 const markerIcon = L.divIcon({ 82 className: "closeIcon_polygon", 83 html: icons.closePolygonIcon, 84 iconSize: [35, 35], 85 iconAnchor: [17.5, 17.5], 86 }); 87 88 // FCS-402: the close "X" is anchored to the polygon's first vertex and has to stay on 89 // top of everything else on the map. Markers share the marker pane and Leaflet z-orders 90 // them by latitude, so without an offset every property pin south of that vertex draws 91 // over the X - worst exactly when the map is zoomed out and the pins are densest. 92 const CLOSE_MARKER_Z_INDEX_OFFSET = 1000; 93 94 /** 95 * Add the polygon's close marker at `latlng` and wire its click handler. 96 * 97 * Every path that draws a polygon - freshly drawn, restored from the URL, restored from 98 * the "zone removed" toast - creates this same marker, so they go through here and 99 * cannot drift apart on icon, stacking or click behaviour. 100 */ 101 const addCloseMarker = function(latlng, onClick) { 102 const closeMarker = L.marker(latlng, { 103 icon: markerIcon, 104 zIndexOffset: CLOSE_MARKER_Z_INDEX_OFFSET, 105 }).addTo(drawnItems); 106 107 closeMarker.on("click", onClick); 108 109 return closeMarker; 110 }; 111 112 /** 113 * Enable or disable every way the user can zoom or pan the map. 114 * 115 * The polygon search locks the viewport while drawing so the user cannot 116 * select an area large enough to error the backend, so all of the zoom 117 * entry points have to be covered - not just the on-screen buttons. 118 */ 119 obj.setZoomEnabled = function(enabled) { 120 const handlers = ['dragging', 'scrollWheelZoom', 'doubleClickZoom', 'touchZoom', 'boxZoom', 'keyboard']; 121 122 handlers.concat('zoomControl').forEach((handler) => { 123 const target = QoetixMap.map[handler]; 124 if (!target) return; 125 126 if (enabled) { 127 target.enable(); 128 } else { 129 target.disable(); 130 } 131 }); 132 }; 133 134 /** 135 * Serialise a ring for `area_of_interest`. 136 * 137 * The backend closes the polygon itself only if the ring is closed, so the first point 138 * is repeated at the end - unless it already is, which is how the ring arrives when it 139 * was parsed back out of the URL. 140 */ 141 const buildCoordsString = function(latlngs) { 142 if (!latlngs || !latlngs.length) return ""; 143 144 const points = latlngs.map((pt) => `${pt.lat} ${pt.lng}`); 145 if (points[points.length - 1] !== points[0]) { 146 points.push(points[0]); 147 } 148 149 return points.join(", "); 150 }; 151 152 /** 153 * Hold on to the zone being removed so the "removed" toast can offer it back. 154 * 155 * Both close-marker paths - the one on a drawn zone and the one on a zone restored from 156 * the URL - land here, so they cannot drift apart on what "restorable" means. 157 */ 158 const rememberZoneForRestore = function(latlngs, coordsStringOverride = "") { 159 lastPolygonLatLngs = latlngs?.length ? latlngs : null; 160 lastPolygonCoordsString = lastPolygonLatLngs ? 161 (coordsStringOverride || buildCoordsString(lastPolygonLatLngs)) : 162 ""; 163 zoneRemovedPending = lastPolygonLatLngs !== null; 164 }; 165 166 /** 167 * Show alert message 168 */ 169 obj.showAlert = function(message, bgColor = "#FAFAF9", display = "block") { 170 if (!polygonMessage) return; 171 zoneRemovedToastActive = false;
172 currentAlertMessage = display === "none" ? null : message; 173 polygonMessage.classList.remove('toast-emphasis-bounce'); 174 polygonMessage.innerText = message; 175 polygonMessage.style.backgroundColor = bgColor; 176 polygonMessage.style.display = display; 177 }; 178 179 /** 180 * Play the toast's one-shot emphasis bounce. 181 * 182 * Removing and re-adding the class in the same tick does not restart a CSS 183 * animation, so the layout is flushed in between. 184 */ 185 const bounceToast = function() { 186 if (!polygonMessage) return; 187 polygonMessage.classList.remove('toast-emphasis-bounce'); 188 void polygonMessage.offsetWidth; 189 polygonMessage.classList.add('toast-emphasis-bounce'); 190 }; 191 192 /** 193 * Show the "zone removed" event toast, with its restore CTA. 194 * 195 * This is the only toast carrying an action, so it builds nodes instead of setting 196 * innerText, and it is styled yellow to separate an event from info and error toasts. 197 */ 198 obj.showZoneRemovedAlert = function() { 199 if (!polygonMessage || !lastPolygonLatLngs) return; 200 201 const message = document.createElement("span"); 202 message.className = "text-black"; 203 message.innerText = polygon_search_zone_removed_text; 204 205 const restore = document.createElement("button"); 206 restore.type = "button"; 207 restore.className = "block underline text-[#003C46] font-semibold border-0 bg-transparent cursor-pointer pl-0 pt-2"; 208 restore.innerText = polygon_search_zone_restore_text; 209 restore.addEventListener("click", obj.restoreLastPolygon); 210 211 polygonMessage.classList.remove("toast-emphasis-bounce"); 212 polygonMessage.replaceChildren(message, restore); 213 polygonMessage.style.backgroundColor = "#FDEBAD"; 214 polygonMessage.style.display = "block"; 215 zoneRemovedToastActive = true; 216 currentAlertMessage = null; 217 218 // The offer is transient: an idle map goes back to its normal message on its own. 219 // A user pan gets there first - on the catalog it reloads the results, and the 220 // rebuilt DOM comes back without the toast. 221 clearTimeout(zoneRemovedTimeout); 222 zoneRemovedTimeout = setTimeout(function () { 223 zoneRemovedToastActive = false; 224 obj.checkZoomForPolygon(); 225 }, ZONE_REMOVED_TOAST_MS); 226 }; 227 228 /** 229 * Put back the zone removed by the last close-marker click and re-run its search. 230 */ 231 obj.restoreLastPolygon = function() { 232 if (!lastPolygonLatLngs) return; 233 234 const latlngs = lastPolygonLatLngs; 235 const coordsStringToRestore = lastPolygonCoordsString; 236 lastPolygonLatLngs = null; 237 lastPolygonCoordsString = ""; 238 zoneRemovedPending = false; 239 240 // The toast is gone the moment it is acted on, so drop its dismissal timer with it - 241 // otherwise it fires later and overwrites whatever message replaced it. 242 clearTimeout(zoneRemovedTimeout); 243 zoneRemovedToastActive = false; 244 245 // Carried on the event rather than shared state: the page that owns the results is 246 // the one that has to re-run the search for the zone being put back. Only the 247 // serialised ring is published - handing out the raw points invites a listener to 248 // re-serialise them and lose the closing point. 249 QoetixMap.map.fire('polygon:restore-last-polygon', { 250 coordsString: coordsStringToRestore, 251 }); 252 253 // The catalog page restarts drawing as soon as a zone is removed; stop it fighting 254 // the zone being put back. 255 const handler = drawControl?._toolbars?.draw?._modes?.polygon?.handler; 256 if (handler?.enabled()) { 257 handler.disable(); 258 } 259 260 // Removing the zone made the page reload every pin. A restored zone shows the 261 // outline on its own - the same state it was in when it was first drawn - and the 262 // pins inside it only come back on the reload that "Mostrar N inmuebles" triggers. 263 QoetixMap.loadMarkers([]); 264 QoetixMap.deleteMarkers(); 265 266 const layer = L.polygon(latlngs, { color: "#003C46", weight: 2 }); 267 drawnItems.addLayer(layer); 268 currentPolygon = layer; 269 drawingActive = false;
270 polygonLoadedOnTab = false; 271 272 addCloseMarker(latlngs[0], obj.handleCloseMarkerClick); 273 setTimeout(() => { 274 closeMarkerEnabled = true; 275 }, 300); 276 277 if (startBtn) { 278 startBtn.style.display = "flex"; 279 } 280 281 // The catalog reloads its results - and rebuilds the map with them - off the restore 282 // event above, and the counts come back with that reload. The zone is still drawn 283 // straight away so the map is not blank while that request is in flight, but asking 284 // for the data here as well would be a second request for the same numbers. 285 obj.onPolygonComplete(layer, !document.body.classList.contains('body-properties')); 286 }; 287 288 /** 289 * Update button text and icon 290 */ 291 obj.updateButtonText = function(propertiesCount = null, loading = false) { 292 if (!startBtnText || !startBtnIcon) return; 293 294 if (loading) { 295 startBtnText.style.display = "none"; 296 startBtnIcon.innerHTML = `<div class="spinner"></div>`; 297 startBtnIcon.style.display = "block"; 298 return; 299 } 300 301 startBtnIcon.classList.remove("drawing-enabled"); 302 303 if (drawingActive) { 304 startBtnText.innerText = polygon_search_cancel_button_text; 305 startBtnIcon.innerHTML = icons.cancel; 306 startBtnIcon.style.display = "block"; 307 startBtnIcon.classList.add("drawing-enabled"); 308 obj.showAlert(polygon_search_enabled_text, "#FAFAF9"); 309 QoetixMap.map.scrollWheelZoom.disable(); 310 return; 311 } 312 313 if (currentPolygon) { 314 if (propertiesCount > 0) { 315 startBtnText.innerText = `Mostrar ${propertiesCount} ${propertiesCount === 1 ? 'inmueble' : 'inmuebles'}`; 316 startBtnText.style.display = "block"; 317 startBtnIcon.style.display = "none"; 318 obj.showAlert(polygon_search_draw_again_text, "#F0B8C7", "none"); 319 startBtn.classList.add("btn--viewEnable"); 320 } else if (propertiesCount === 0) { 321 if (document.body.classList.contains('body-properties')) { 322 startBtnText.innerText = polygon_search_enabled_button_text; 323 startBtnText.style.display = "block"; 324 startBtnIcon.innerHTML = icons.default; 325 startBtnIcon.style.display = "block"; 326 obj.showAlert(polygon_search_draw_again_text_catalog, "#F0B8C7"); 327 startBtn.style.display = "none"; 328 } else { 329 startBtnText.innerText = `Mostrar 0 inmuebles`; 330 startBtnText.style.display = "block"; 331 startBtnIcon.style.display = "none"; 332 obj.showAlert(polygon_search_draw_again_text, "#F0B8C7"); 333 } 334 335 if (mapContainer) { 336 mapContainer.dispatchEvent(new CustomEvent('polygon:no-properties-found')); 337 } 338 } 339 return; 340 } 341 342 // Default state 343 startBtnText.innerText = polygon_search_enabled_button_text; 344 startBtnText.style.display = "block"; 345 startBtnIcon.innerHTML = icons.default; 346 startBtnIcon.style.display = "block"; 347 }; 348 349 /** 350 * Get polygon data from API 351 */ 352 obj.getPolygonData = function() { 353 const urlParams = new URLSearchParams(window.location.search); 354 const params = new URLSearchParams({ 355 area_of_interest: coordsString, 356 }); 357 358 params.append("sale_rent", obj.saleRent); 359 360 fetch("/api/property-public/polygon-data/?" + params.toString(), { 361 method: "GET", 362 headers: { 363 "Content-type": "application/json; charset=UTF-8", 364 }, 365 }) 366 .then((response) => response.json()) 367 .then((data) => { 368 polygonDataCount = Array.isArray(data) ? data.length : 0; 369 obj.updateButtonText(polygonDataCount, false); 370 // This will display the polygon markers on the map. Disabling it for now. 371 // QoetixMap.loadMarkers(data, false, true); 372 }) 373 .catch((error) => console.error("Error:", error)); 374 }; 375 376 /** 377 * Handle polygon completion 378 */ 379 obj.onPolygonComplete = function(polygonLayer, fetchData = true) { 380 // The viewport is locked only to constrain the drawing gesture. The shape is fixed 381 // now, so hand zoom and pan straight back - the user still has to be able to look 382 // around the zone they just drew, and checkZoomForPolygon keeps the button usable 383 // once results exist. 384 obj.setZoomEnabled(true); 385 obj.updateButtonText(null, true); 386 obj.showAlert(null, null, "none"); 387 coordsString = buildCoordsString(polygonLayer.getLatLngs()[0]); 388 389 if (fetchData) { 390 if (obj.area_restriction_options?.area_restriction === "on") { 391 obj.checkWithLocation(coordsString); 392 } else { 393 obj.getPolygonData(); 394 } 395 } 396 397 QoetixMap.map.fire('polygon:drawing-completed', { 398 coordsString: coordsString 399 }); 400 }; 401 402 /** 403 * Test whether a [lng, lat] point falls inside a single [lng, lat] ring. 404 */
405 const pointInRing = function(lng, lat, ring) { 406 let inside = false; 407 408 for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { 409 const [lngI, latI] = ring[i]; 410 const [lngJ, latJ] = ring[j]; 411 412 if ((latI > lat) !== (latJ > lat) && lng < ((lngJ - lngI) * (lat - latI)) / (latJ - latI) + lngI) { 413 inside = !inside; 414 } 415 } 416 417 return inside; 418 }; 419 420 /** 421 * Orientation of the ordered triplet (p, q, r): 0 collinear, 1 clockwise, 2 counter-clockwise. 422 */ 423 const orientation = function(p, q, r) { 424 const value = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]); 425 426 if (value === 0) return 0; 427 428 return value > 0 ? 1 : 2; 429 }; 430 431 /** 432 * Test whether collinear point q lies on segment pr. 433 */ 434 const onSegment = function(p, q, r) { 435 return q[0] <= Math.max(p[0], r[0]) && q[0] >= Math.min(p[0], r[0]) && 436 q[1] <= Math.max(p[1], r[1]) && q[1] >= Math.min(p[1], r[1]); 437 }; 438 439 /** 440 * Test whether segments p1q1 and p2q2 cross. 441 */ 442 const segmentsIntersect = function(p1, q1, p2, q2) { 443 const o1 = orientation(p1, q1, p2); 444 const o2 = orientation(p1, q1, q2); 445 const o3 = orientation(p2, q2, p1); 446 const o4 = orientation(p2, q2, q1); 447 448 if (o1 !== o2 && o3 !== o4) return true; 449 if (o1 === 0 && onSegment(p1, p2, q1)) return true; 450 if (o2 === 0 && onSegment(p1, q2, q1)) return true; 451 if (o3 === 0 && onSegment(p2, p1, q2)) return true; 452 if (o4 === 0 && onSegment(p2, q1, q2)) return true; 453 454 return false; 455 }; 456 457 /** 458 * Test whether any edge of ringA crosses any edge of ringB. 459 */ 460 const ringsIntersect = function(ringA, ringB) { 461 for (let i = 0, j = ringA.length - 1; i < ringA.length; j = i++) { 462 for (let k = 0, l = ringB.length - 1; k < ringB.length; l = k++) { 463 if (segmentsIntersect(ringA[j], ringA[i], ringB[l], ringB[k])) { 464 return true; 465 } 466 } 467 } 468 469 return false; 470 }; 471 472 /** 473 * Load the outer rings of a restriction area, keeping the parsed result for the session. 474 * 475 * The area files are a few hundred KB, so they are only fetched and parsed once per area code. 476 */ 477 obj.loadAreaRings = function(areaCode) { 478 if (areaRingsCache[areaCode]) { 479 return areaRingsCache[areaCode]; 480 } 481 482 areaRingsCache[areaCode] = fetch(`/plugins/GADM/json/${areaCode}.json`) 483 .then((response) => { 484 if (!response.ok) { 485 throw new Error(`Could not load area ${areaCode}: ${response.status}`); 486 } 487 488 return response.json(); 489 }) 490 .then((data) => { 491 const geometry = data?.features?.[0]?.geometry; 492 493 if (!geometry?.coordinates) { 494 throw new Error(`Area ${areaCode} has no geometry`); 495 } 496 497 // Flatten Polygon and MultiPolygon down to a list of outer rings. 498 return geometry.type === "Polygon" ? 499 [geometry.coordinates[0]] : 500 geometry.coordinates.map((polygon) => polygon[0]); 501 }) 502 .catch((error) => { 503 // Do not cache a failure, so the next polygon retries the fetch. 504 delete areaRingsCache[areaCode]; 505 throw error; 506 }); 507 508 return areaRingsCache[areaCode]; 509 }; 510 511 /** 512 * Test whether the drawn zone overlaps the restriction area at all. 513 * 514 * The area is only unavailable when the zone lies *entirely* outside it, so a zone that 515 * merely touches the area must pass. Testing the drawn vertices alone is not enough: a zone 516 * can swallow an island whole, or straddle a coastline, without a single vertex of either 517 * shape landing inside the other. 518 */ 519 obj.polygonOverlapsArea = function(drawnRing, areaRings) { 520 // A corner of the drawn zone sits inside the area. 521 if (drawnRing.some(([lng, lat]) => areaRings.some((ring) =>
521 pointInRing(lng, lat, ring)))) { 522 return true; 523 } 524 525 // The drawn zone encloses part of the area outright. 526 if (areaRings.some((ring) => ring.some(([lng, lat]) => pointInRing(lng, lat, drawnRing)))) { 527 return true; 528 } 529 530 // The zone straddles the border with no vertex of either shape inside the other. 531 return areaRings.some((ring) => ringsIntersect(drawnRing, ring)); 532 }; 533 534 /** 535 * Check if the drawn polygon falls under the provided location 536 */ 537 obj.checkWithLocation = function(coordsString) { 538 // The area rings are [lng, lat]; line up the drawn zone with them. 539 const drawnRing = coordsString.split(",").map((coord) => { 540 const [lat, lng] = coord.trim().split(/\s+/).map(Number); 541 542 return [lng, lat]; 543 }); 544 const areaCode = obj.area_restriction_options?.area_country_code; 545 546 obj.loadAreaRings(areaCode) 547 .then((areaRings) => { 548 if (obj.polygonOverlapsArea(drawnRing, areaRings)) { 549 obj.getPolygonData(); 550 551 return; 552 } 553 554 startBtn.style.display = "none"; 555 obj.showAlert(polygon_search_area_unavailable_text, "#F0B8C7"); 556 }) 557 .catch((error) => { 558 // A missing or broken area file must not strand the button on its loading 559 // spinner, so fall back to an unrestricted search. 560 console.error("Error:", error); 561 obj.getPolygonData(); 562 }); 563 }; 564 565 /** 566 * Distance in metres across the visible map, edge to edge. 567 * 568 * Measured on the shorter side of the container rather than corner to 569 * corner, so the value describes the span the user can actually take in and 570 * does not swing with the container's aspect ratio. 571 */ 572 obj.getVisibleDistanceMeters = function() { 573 const map = QoetixMap.map; 574 const size = map.getSize(); 575 576 const [edgeA, edgeB] = size.x > size.y 577 ? [L.point(size.x / 2, 0), L.point(size.x / 2, size.y)] 578 : [L.point(0, size.y / 2), L.point(size.x, size.y / 2)]; 579 580 return map.containerPointToLatLng(edgeA) 581 .distanceTo(map.containerPointToLatLng(edgeB)); 582 }; 583 584 /** 585 * Check zoom level for polygon drawing 586 */ 587 obj.checkZoomForPolygon = function() { 588 if (!startBtn) return; 589 590 // Only zooming out disables the tool - at the map's deepest zoom the 591 // view is still inside the drawable range, so the button stays live. 592 // This runs on every moveend, programmatic ones included - the pins landing 593 // after a reload re-apply the view and fire one. The "zone removed" toast has 594 // its own timer, so while it is up only the button state is updated here. 595 const toastLocked = zoneRemovedToastActive; 596 597 const distanceKm = obj.getVisibleDistanceMeters() / 1000; 598 if (distanceKm <= MAX_DRAW_DISTANCE_KM) { 599 const startPolygonBtnIcon = document.getElementById("startPolygonBtnIcon"); 600 startBtn.style.pointerEvents = "auto"; 601 startBtn.classList.remove("btn--disabled"); 602 603 if (toastLocked) return; 604 605 if (startPolygonBtnIcon && startPolygonBtnIcon.classList.contains("drawing-enabled")) { 606 obj.showAlert(polygon_search_enabled_text, "#FAFAF9"); 607 } else { 608 obj.showAlert(polygon_search_enabled_text, "#FAFAF9", "none"); 609 } 610 } else { 611 // Stays clickable: FCS-399 wants a click on the disabled button to bounce 612 // the toast again, and a button with pointer-events off never sees one. 613 startBtn.style.pointerEvents = "auto"; 614 startBtn.classList.add("btn--disabled"); 615 if (!toastLocked) { 616 // Bounce when the message appears, not on every pan or zoom notch 617 // while it is already up. 618 const alreadyShown = currentAlertMessage === polygon_search_disabled_text; 619 obj.showAlert(polygon_search_disabled_text); 620 if (!alreadyShown) { 621 bounceToast(); 622 } 623 } 624 if (startBtn.classList.contains('btn--viewEnable')) { 625 startBtn.style.pointerEvents = "auto"; 626 startBtn.classList.remove("btn--disabled"); 627 if (!toastLocked) { 628 obj.showAlert("", "", "none"); 629 } 630 } 631 } 632 }; 633 634 /** 635 * Cancel an in-progress drawing and hand the map back to the user. 636 * 637 * Every interruption has to come through here: drawing locks the viewport via 638 * setZoomEnabled(false), so a drawing that ends without this leaves the map frozen 639 * with nothing left on screen to explain why. 640 * 641 * @param {boolean} fireEvent Whether to report a user-initiated cancel to analytics. 642 * @return {boolean} Whether there was a drawing to cancel. 643 */ 644 obj.cancelDrawing = function(fireEvent = true) { 645 if (!drawingActive) return false;
646 647 QoetixMap.loadMarkers([]); 648 QoetixMap.deleteMarkers(); 649 650 const handler = drawControl?._toolbars?.draw?._modes?.polygon?.handler; 651 if (handler) { 652 handler.disable(); 653 } 654 655 drawingActive = false; 656 obj.setZoomEnabled(true); 657 obj.updateButtonText(); 658 obj.showAlert(polygon_search_enabled_text, '', 'none'); 659 660 if (fireEvent && startBtn) { 661 startBtn.dispatchEvent(new CustomEvent('polygon:drawing-cancelled-btn')); 662 } 663 664 return true; 665 }; 666 667 /** 668 * Reset map functions to default state 669 */ 670 obj.resetMapFunctions = function() { 671 QoetixMap.map.setView([40.39842296004511, -3.6977145712057866], 5); 672 drawnItems.clearLayers(); 673 currentPolygon = null; 674 coordsString = ""; 675 drawingActive = false; 676 polygonDataCount = 0; 677 closeMarkerEnabled = false; 678 polygonLoadedOnTab = false; 679 obj.showAlert(polygon_search_disabled_text); 680 obj.updateButtonText(); 681 }; 682 683 /** 684 * Handle close marker click 685 */ 686 obj.handleCloseMarkerClick = function(fireEvent = true) { 687 if (!closeMarkerEnabled) return; 688 689 rememberZoneForRestore( 690 currentPolygon ? 691 currentPolygon.getLatLngs()[0].map((pt) => ({ lat: pt.lat, lng: pt.lng })) : 692 null, 693 coordsString, 694 ); 695 696 startBtn.style.display = "flex"; 697 closeMarkerEnabled = false; 698 drawnItems.clearLayers(); 699 currentPolygon = null; 700 coordsString = ""; 701 drawingActive = false; 702 polygonLoadedOnTab = false; 703 obj.showAlert(polygon_search_enabled_text,'','none'); 704 QoetixMap.loadMarkers([]); 705 QoetixMap.deleteMarkers(); 706 obj.updateButtonText(); 707 obj.setZoomEnabled(true); 708 709 if (document.body.classList.contains('body-properties')) { 710 const handler = drawControl?._toolbars?.draw?._modes?.polygon?.handler; 711 if (handler) { 712 handler.enable(); 713 drawingActive = true; 714 obj.updateButtonText(); 715 } 716 } 717 718 if (fireEvent) { 719 // Removing the zone switches the search to the area the user is looking at 720 // rather than dropping the restriction, so both close-marker paths - this 721 // one on a drawn zone and the one on a zone restored from the URL - fire 722 // the same event. Gating this on the URL left a freshly drawn zone falling 723 // back to the old clear-everything behaviour. 724 QoetixMap.map.fire("polygon:start-visual-map-search", obj.getMapBounds()); 725 } else { 726 // No reload follows, so the toast shown below is the only one there will 727 // be - a later reload must not put it back a second time. 728 zoneRemovedPending = false; 729 } 730 731 if (startBtn.classList.contains("btn--viewEnable")) { 732 startBtn.classList.remove("btn--viewEnable"); 733 obj.checkZoomForPolygon(); 734 } 735 736 // Last, so it is not overwritten by updateButtonText or checkZoomForPolygon above. 737 obj.showZoneRemovedAlert(); 738 }; 739 740 /** 741 * Disable Drawing 742 */ 743 obj.disableDrawing = function () { 744 const handler = drawControl?._toolbars?.draw?._modes?.polygon?.handler; 745 if (handler) { 746 handler.disable(); 747 drawingActive = false; 748 } 749 }; 750 751 /** 752 * Draw area of interest polygon from URL parameter 753 */ 754 obj.drawAreaOfInterestPolygon = function(areaOfInterest) { 755 if (polygonClosedByUser) { 756 return; 757 } 758 759 if (polygonLoadedOnTab) { 760 // Invalidate map size in case container changed (e.g., tab switch) 761 QoetixMap.map.invalidateSize(); 762 763 // Get bounds from the already drawn polygon 764 const layers = drawnItems.getLayers(); 765 if (layers.length > 0) { 766 const polygon = layers.find(layer => layer instanceof L.Polygon); 767 if (polygon) { 768 const bounds = polygon.getBounds(); 769 QoetixMap.map.whenReady(() => { 770 QoetixMap.map.fitBounds(bounds, { padding: [20, 20] }); 771 }); 772 } 773 } 774 return; 775 } 776 polygonLoadedOnTab = true; 777 778 drawnItems.clearLayers(); 779 780 const coords = areaOfInterest.split(",").map((pair) => { 781 const [lat, lng] = pair.trim().split(" ").map(Number); 782 return [lat, lng]; 783 }); 784 785 const polygon = L.polygon(coords, { color: "#003C46", weight: 2 }).addTo(drawnItems); 786 const bounds = polygon.getBounds(); 787 788 // Fit bounds after ensuring map is fully loaded 789 const fitBoundsToPolygon = () => { 790 QoetixMap.map.invalidateSize(); 791 requestAnimationFrame(() => { 792 // fitBounds() already stops at the map's maxZoom, so a small 793 // polygon still gets the closest view that holds it. Jumping 794 // straight to maxZoom instead centres the map inside the polygon 795 // and leaves its edges off screen. 796 QoetixMap.map.fitBounds(bounds, { padding: [20, 20] }); 797 }); 798 }; 799 800 // Try to wait for map load event, with timeout fallback 801 let loadHandled = false;
802 const loadHandler = () => { 803 if (!loadHandled) { 804 loadHandled = true; 805 QoetixMap.map.off('load', loadHandler); 806 fitBoundsToPolygon(); 807 } 808 }; 809 810 QoetixMap.map.on('load', loadHandler); 811 812 // Fallback timeout in case load event doesn't fire 813 setTimeout(() => { 814 if (!loadHandled) { 815 loadHandled = true; 816 QoetixMap.map.off('load', loadHandler); 817 fitBoundsToPolygon(); 818 } 819 }, 300); 820 821 addCloseMarker(coords[0], function() { 822 drawnItems.clearLayers(); 823 currentPolygon = null; 824 coordsString = ""; 825 drawingActive = false; 826 polygonLoadedOnTab = false; 827 obj.showAlert(polygon_search_enabled_text); 828 QoetixMap.loadMarkers([]); 829 QoetixMap.deleteMarkers(); 830 obj.updateButtonText(); 831 832 if (startBtn) { 833 startBtn.style.display = "flex"; 834 } 835 obj.initPolygonDrawing(); 836 837 QoetixMap.map.fire('polygon:start-visual-map-search', obj.getMapBounds()); 838 rememberZoneForRestore( 839 coords.map(([lat, lng]) => ({ lat, lng })), 840 areaOfInterest, 841 ); 842 polygonClosedByUser = true; 843 }); 844 }; 845 846 /** 847 * Setup area of interest mode 848 */ 849 obj.setupAreaOfInterestMode = function(areaOfInterest) { 850 polygonClosedByUser = false; 851 const polygonSearchUI = document.querySelectorAll('.polygon_search--ui'); 852 853 if (startBtn) { 854 startBtn.style.display = "none"; 855 } 856 857 polygonSearchUI.forEach((el) => { 858 el.style.display = "block"; 859 }); 860 861 QoetixMap.map.off('zoomend'); 862 QoetixMap.map.on('zoomend', function() { 863 if (QoetixMap.map.getZoom() >= QoetixMap.map.getMaxZoom()) { 864 QoetixMap.refreshMarkers(false); 865 } else if (QoetixMap.options.refresh_markers) { 866 QoetixMap.refreshMarkers(false); 867 } 868 }); 869 870 const originalLoadMarkers = QoetixMap.loadMarkers; 871 QoetixMap.loadMarkers = function(markers, show_circle = false, ignore_fit_bounds = false) { 872 this.markers = Array.isArray(markers) ? markers : []; 873 if (!this.markers.length) return; 874 875 const prevFitBoundsFlag = QoetixMap.options.fit_bounds_first_time; 876 QoetixMap.options.fit_bounds_first_time = false; 877 originalLoadMarkers.call(this, markers, show_circle, ignore_fit_bounds); 878 QoetixMap.options.fit_bounds_first_time = prevFitBoundsFlag; 879 }; 880 881 QoetixMap.resetMapPosition = function() { 882 // Invalidate size first, then redraw when map is ready 883 QoetixMap.map.invalidateSize(); 884 QoetixMapPolygon.drawAreaOfInterestPolygon(areaOfInterest); 885 }; 886 887 // Initial draw - invalidate size first to ensure container is ready 888 QoetixMap.map.invalidateSize(); 889 polygonLoadedOnTab = false; 890 QoetixMapPolygon.drawAreaOfInterestPolygon(areaOfInterest); 891 892 }; 893 894 /** 895 * Initialize polygon drawing functionality 896 */ 897 obj.initPolygonDrawing = function() { 898 drawnItems.clearLayers(); 899 coordsString = ""; 900 polygonDataCount = 0; 901 closeMarkerEnabled = false; 902 903 // Leaflet.draw control - enable polygon drawing 904 drawControl = new L.Control.Draw({ 905 edit: { featureGroup: drawnItems, remove: false }, 906 draw: { 907 polygon: { 908 shapeOptions: { 909 color: "#003C46", 910 weight: 2, 911 }, 912 }, 913 polyline: false, 914 rectangle: false, 915 circle: false, 916 circlemarker: false, 917 marker: false, 918 }, 919 }); 920 QoetixMap.map.addControl(drawControl); 921 922 // Initial check & listen to zoom/pan 923 obj.checkZoomForPolygon(); 924 QoetixMap.map.on("zoomend moveend", obj.checkZoomForPolygon); 925 926 // Start Polygon button click 927 startBtn.addEventListener("click", function() { 928 // FCS-399: too zoomed out to draw. The toast already says so; a click on the 929 // disabled button bounces it again so the message is not missed. 930 if (startBtn.classList.contains("btn--disabled")) { 931 obj.showAlert(polygon_search_disabled_text); 932 bounceToast(); 933 return; 934 } 935 936 if (drawingActive) { 937 obj.cancelDrawing(); 938 } else if (currentPolygon && polygonDataCount >= 0) { 939 // FCS-388: the results take a moment to arrive - a full page load from 940 // the home page, a section reload on the catalog - so the button shows 941 // it is working and takes no second click meanwhile. The DOM that 942 // replaces it comes back in the default state. 943 obj.updateButtonText(null, true); 944 if (polygonDataCount > 0) { 945 startBtn.style.pointerEvents = "none"; 946 } 947 startBtn.dispatchEvent(new CustomEvent('polygon:drawing-finished-btn', { 948 detail: { 949 coordsString: coordsString, 950 polygonDataCount: polygonDataCount 951 } 952 })); 953 } else { 954 if (currentPolygon) { 955 drawnItems.clearLayers(); 956 currentPolygon = null; 957 polygonDataCount = 0; 958 coordsString = ""; 959 obj.showAlert(polygon_search_enabled_text); 960 } 961 drawnItems.clearLayers(); 962 currentPolygon = null; 963 coordsString = ""; 964 polygonDataCount = 0; 965 obj.showAlert(polygon_search_enabled_text); 966 967 drawControl._toolbars.draw._modes.polygon.handler.enable(); 968 drawingActive = true; 969 closeMarkerEnabled = false;
970 // Drawing a new zone supersedes any offer to restore the old one. 971 rememberZoneForRestore(null); 972 obj.updateButtonText(); 973 974 startBtn.dispatchEvent(new Event('polygon:drawing-started-btn')); 975 976 QoetixMap.loadMarkers([]); 977 QoetixMap.deleteMarkers(); 978 obj.setZoomEnabled(false); 979 } 980 }); 981 982 // Polygon creation 983 QoetixMap.map.on("draw:created", function(event) { 984 const layer = event.layer; 985 986 if (currentPolygon) drawnItems.clearLayers(); 987 988 drawnItems.addLayer(layer); 989 currentPolygon = layer; 990 991 drawingActive = false; 992 obj.updateButtonText(); 993 994 if (layer instanceof L.Polygon) { 995 const latlngs = layer.getLatLngs()[0]; 996 addCloseMarker(latlngs[0], obj.handleCloseMarkerClick); 997 998 setTimeout(() => { 999 closeMarkerEnabled = true; 1000 }, 300); 1001 } 1002 1003 obj.onPolygonComplete(layer); 1004 }); 1005 1006 // Limit polygon to 10 clicks 1007 QoetixMap.map.on("draw:drawstart", function(e) { 1008 currentVertexCount = 0; 1009 }); 1010 1011 QoetixMap.map.on("draw:drawvertex", function(e) { 1012 obj.showAlert(polygon_search_enabled_text,'','none'); 1013 currentVertexCount++; 1014 if (currentVertexCount >= MAX_POLYGON_POINTS) { 1015 setTimeout(() => { 1016 const handler = drawControl._toolbars.draw._modes.polygon.handler; 1017 handler.completeShape(); 1018 }, 50); 1019 } 1020 }); 1021 1022 // Setup close button 1023 const polygonCloseBtn = document.getElementById("polygon_search_modal--close"); 1024 if (polygonCloseBtn) { 1025 polygonCloseBtn.addEventListener("click", () => { 1026 setTimeout(() => { 1027 obj.resetMapFunctions(); 1028 }, 300); 1029 }); 1030 } 1031 }; 1032 1033 /** 1034 * Render and initialize map polygon functionality 1035 */ 1036 obj.render = function(map_id = '', area_restriction_options = {}) { 1037 obj.area_restriction_options = area_restriction_options; 1038 if (map_id === '') { 1039 const polygonMaps = document.querySelectorAll('.qoetix-map-container.polygon_map_search'); 1040 1041 if (!polygonMaps.length) return; 1042 // obj.initPolygonSearch(); 1043 polygonMaps.forEach((mapElement) => { 1044 mapElement.addEventListener('map:loaded', function() { 1045 obj.initPolygonSearch(); 1046 }); 1047 }); 1048 return; 1049 } 1050 1051 const mapElement = document.getElementById(map_id); 1052 if (!mapElement) return; 1053 1054 mapElement.addEventListener('map:loaded', function() { 1055 obj.initPolygonSearch(); 1056 }); 1057 }; 1058 1059 /** 1060 * Initialize polygon search 1061 */ 1062 obj.initPolygonSearch = function() { 1063 if (QoetixMap.map._polygonSearchInitialized) { 1064 return; 1065 } 1066 1067 QoetixMap.map._polygonSearchInitialized = true; 1068 const waitForMapToInitialize = setInterval(() => { 1069 clearInterval(waitForMapToInitialize); 1070 // Initialize DOM elements 1071 startBtn = document.getElementById("startPolygonBtn"); 1072 startBtnIcon = document.getElementById("startPolygonBtnIcon"); 1073 startBtnText = document.getElementById("startPolygonBtnText"); 1074 polygonMessage = document.querySelector("#polygon_message"); 1075 mapContainer = document.getElementById("map-polygon") || QoetixMap.map.getContainer().closest('.qoetix-map-container'); 1076 1077 // Initialize Leaflet feature group 1078 drawnItems = new L.FeatureGroup(); 1079 QoetixMap.map.addLayer(drawnItems); 1080 1081 // Check for area of interest in URL 1082 const urlParams = new URLSearchParams(window.location.search); 1083 const areaOfInterest = urlParams.get("area_of_interest"); 1084 const initVisibleMap = urlParams.get("visible_map") === "true"; 1085 obj.updateButtonText(); 1086 1087 if (areaOfInterest && initVisibleMap) { 1088 obj.initVisibleMapPolygonDrawing(areaOfInterest); 1089 // Restore button after AJAX initialization 1090 drawnItems.clearLayers(); 1091 currentPolygon = null; 1092 drawingActive = false;
1093 obj.updateButtonText(); 1094 } else if (areaOfInterest) { 1095 obj.setupAreaOfInterestMode(areaOfInterest); 1096 } else { 1097 obj.initPolygonDrawing(); 1098 } 1099 1100 }, 100); 1101 }; 1102 1103 /** 1104 * Enable both polygon draw function & user movements page load 1105 */ 1106 obj.initVisibleMapPolygonDrawing = function (areaOfInterest) { 1107 QoetixMap.map.invalidateSize(); 1108 const coords = areaOfInterest.split(",").map((pair) => { 1109 const [lat, lng] = pair.trim().split(" ").map(Number); 1110 return [lat, lng]; 1111 }); 1112 const bounds = L.latLngBounds(coords); 1113 const view = obj.lastVisibleMapView; 1114 obj.lastVisibleMapView = null; 1115 1116 // The pins are only ever whatever the visible area happened to contain, so 1117 // fitting the view to them pulls the user off the area they just searched - 1118 // and a lone pin takes the fit all the way to the maximum zoom. The drawn-zone 1119 // branch guards this the same way. resetMap() switches the flag back on for 1120 // every reload, so it has to be switched off again on every one. 1121 QoetixMap.options.fit_bounds_first_time = false; 1122 1123 // Showing the map again - a tab switch, a container that was hidden at init - 1124 // must not re-centre on the markers either. Visible-area mode only ever needs 1125 // a re-measure at the view already in place. 1126 QoetixMap.resetMapPosition = function () { 1127 const center = QoetixMap.map.getCenter(); 1128 const zoom = QoetixMap.map.getZoom(); 1129 QoetixMap.map.invalidateSize();
1130 QoetixMap.map.setView(center, zoom, { animate: false }); 1131 }; 1132 1133 QoetixMap.map.whenReady(() => { 1134 requestAnimationFrame(() => { 1135 // The ring is a viewport, so fitting to it snaps the zoom and ends up 1136 // showing a wider area than was actually searched - and every pan would 1137 // widen it again. Whenever the view that produced the ring is still 1138 // known - every reload except a cold page load - put that exact view 1139 // back so the map holds still under the user. 1140 if (view) { 1141 QoetixMap.map.setView(view.center, view.zoom, { animate: false }); 1142 } else { 1143 QoetixMap.map.fitBounds(bounds); 1144 } 1145 obj.initPolygonDrawing(); 1146 1147 // Removing a zone reloads the results - and with them the whole map - 1148 // which throws away the toast offering that zone back. The offer 1149 // survives in module state, so put it back on the rebuilt DOM, after 1150 // initPolygonDrawing so its zoom check does not overwrite it. One shot: 1151 // the pans and filter changes that follow must not resurrect it. 1152 if (zoneRemovedPending) { 1153 zoneRemovedPending = false; 1154 obj.showZoneRemovedAlert(); 1155 } 1156 }); 1157 }); 1158 }; 1159 1160 1161 /** 1162 * Get Map Bounds 1163 */ 1164 obj.getMapBounds = function () { 1165 const bounds = QoetixMap.map.getBounds(); 1166 1167 // The reload that follows rebuilds the map from scratch, so the view the ring 1168 // was read from is the only record of where the user actually was. 1169 obj.lastVisibleMapView = { 1170 center: QoetixMap.map.getCenter(), 1171 zoom: QoetixMap.map.getZoom(), 1172 }; 1173 1174 return { 1175 area_of_interest: buildCoordsString([ 1176 bounds.getNorthWest(), 1177 bounds.getNorthEast(), 1178 bounds.getSouthEast(),
1179 bounds.getSouthWest() 1180 ]) 1181 }; 1182 }; 1183 1184 /** 1185 * Track user interaction on map 1186 */ 1187 obj.mapUserInteracting = false; 1188 obj.mapMovedTimeout = null; 1189 obj.lastVisibleMapView = null; 1190 1191 /** 1192 * Whether a zone is on the map - drawn, or in the middle of being drawn. 1193 */ 1194 const hasDrawnZone = function () { 1195 return drawingActive || currentPolygon !== null; 1196 }; 1197 1198 obj.initMapMovedEvent = function () { 1199 if (!QoetixMap.map) { 1200 return; 1201 } 1202 1203 const map = QoetixMap.map; 1204 const container = map.getContainer(); 1205 if (map._qoetixMapMovedEventsInitialized) { 1206 return; 1207 } 1208 map._qoetixMapMovedEventsInitialized = true; 1209 1210 // The map is rebuilt on every reload while the flags below outlive it, so a 1211 // gesture cut short by one must not count as interaction on the new map. 1212 obj.mapUserInteracting = false; 1213 clearTimeout(obj.mapMovedTimeout); 1214 1215 // A fresh gesture means the user has not finished moving, so the search queued 1216 // by the previous one is dropped rather than allowed to fire underneath them. 1217 const startInteraction = function () { 1218 clearTimeout(obj.mapMovedTimeout); 1219 obj.mapUserInteracting = true; 1220 }; 1221 1222 // Only ever wired to real input events. Leaflet's own "zoomstart"/"movestart" 1223 // fire for programmatic moves as well - the view restore below being one - so 1224 // hanging this off those would have the map re-searching after its own reload. 1225 // User dragging 1226 map.on("dragstart", startInteraction); 1227 1228 // User double click zoom 1229 container.addEventListener("dblclick", startInteraction, true); 1230 1231 // User wheel zoom 1232 container.addEventListener( 1233 "wheel", 1234 function (e) { 1235 if ( 1236 e.target.closest && 1237 e.target.closest(".leaflet-control") 1238 ) { 1239 return; 1240 } 1241 startInteraction(); 1242 }, 1243 true 1244 ); 1245 1246 // User zoom controls 1247 container.addEventListener( 1248 "pointerdown", 1249 function (e) { 1250 if ( 1251 e.target.closest && 1252 e.target.closest(".leaflet-control-zoom") 1253 ) { 1254 startInteraction(); 1255 } 1256 }, 1257 true 1258 ); 1259 1260 // User touch / pinch 1261 container.addEventListener( 1262 "touchstart", 1263 function (e) { 1264 if ( 1265 e.target.closest && 1266 e.target.closest(".leaflet-control") 1267 ) { 1268 return; 1269 } 1270 startInteraction(); 1271 }, 1272 true 1273 ); 1274 1275 // Map movement finished 1276 map.on("moveend", function () { 1277 // Programmatic movement - a setView, a fitBounds, the marker fit - never 1278 // sets the interaction flag, so that flag is the only gate needed. Marking 1279 // the next moveend as ignorable before a reload instead relies on a move 1280 // that may not happen, and then swallows the user's following pan. 1281 if (!obj.mapUserInteracting) { 1282 return; 1283 } 1284 1285 obj.mapUserInteracting = false; 1286 clearTimeout(obj.mapMovedTimeout); 1287 1288 // A drawn zone is the search - the map only follows the viewport once the 1289 // zone is gone. This handler stays wired while the user draws on a 1290 // visible-area map, so without the check the first zoom after closing a 1291 // shape re-ran the visible-area search and wiped the zone with the reload. 1292 if (hasDrawnZone()) { 1293 return; 1294 } 1295 1296 obj.mapMovedTimeout = setTimeout(function () { 1297 // The zone may have been drawn while this was pending. 1298 if (hasDrawnZone()) { 1299 return; 1300 } 1301 1302 // Flagged as map-driven so the reload replaces the history entry 1303 // instead of stacking one up per pan. 1304 map.fire("polygon:start-visual-map-search", { 1305 ...obj.getMapBounds(), 1306 from_map_move: true, 1307 }); 1308 }, VISIBLE_MAP_SEARCH_DEBOUNCE_MS); 1309 }); 1310 }; 1311 1312 return obj; 1313} 1314 1315const QoetixMapPolygon = LeafletMapPolygon();
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.