PageSourceSearch

https://faciliteacasa.com/js/polygon-search.js?1790087158

js faciliteacasa.com collected 2026-10-08 23:16:19 UTC 51,976 bytes, 1,315 lines download raw bytes

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.