PageSourceSearch

https://johndjohnson.info/site_libs/legend-interactivity-1.0.0/legend-interactivity.js

js johndjohnson.info collected 2026-10-08 18:30:03 UTC 38,964 bytes, 1,146 lines download raw bytes

1/**
2 * Legend Interactivity Module for mapgl
3 * Shared functionality for both Mapbox GL and MapLibre GL
4 *
5 * Provides interactive legend capabilities:
6 * - Categorical legends: Click to toggle category visibility
7 * - Continuous legends: Dual-handle range slider for filtering
8 */
9
10/**
11 * Main initialization function - called from widget code
12 * @param {Object} map - The map instance (mapboxgl or maplibre)
13 * @param {string} mapId - The container ID for the map
14 * @param {Object} config - Configuration from R side
15 */
16function initializeLegendInteractivity(map, mapId, config) {
17    var legendElement = document.getElementById(config.legendId);
18    if (!legendElement) {
19        console.warn("Legend element not found:", config.legendId);
20        return;
21    }
22
23    // Ensure layer state tracking exists
24    if (!window._mapglLayerState) {
25        window._mapglLayerState = {};
26    }
27    if (!window._mapglLayerState[mapId]) {
28        window._mapglLayerState[mapId] = {
29            filters: {},
30            paintProperties: {},
31            layoutProperties: {},
32            tooltips: {},
33            popups: {},
34            legends: {},
35            interactiveFilters: {}
36        };
37    }
38
39    var layerState = window._mapglLayerState[mapId];
40
41    // Ensure interactiveFilters exists (state object may have been created
42    // by map init code without this property)
43    if (!layerState.interactiveFilters) {
44        layerState.interactiveFilters = {};
45    }
46
47    // Normalize layerId to array for multi-layer support
48    var layerIds = Array.isArray(config.layerId) ? config.layerId : [config.layerId];
49
50    // Defer if style isn't loaded or any specified layer doesn't exist yet
51    // (handles race conditions in Shiny where layers are added via proxy)
52    var allLayersExist = map.isStyleLoaded() && layerIds.every(function(lid) {
53        return !lid || map.getLayer(lid);
54    });
55    if (!allLayersExist) {
56        var retryCount = config._retryCount || 0;
57        if (retryCount < 10) {
58            config._retryCount = retryCount + 1;
59            map.once('idle', function() {
60                initializeLegendInteractivity(map, mapId, config);
61            });
62        } else {
63            console.warn(
64                "Legend interactivity: not all layers found after retries. " +
65                "Missing layers for legend: " + config.legendId
66            );
67        }
68        return;
69    }
70
71    // Initialize interactive filter state for each layer
72    layerIds.forEach(function(lid) {
73        if (lid && !layerState.interactiveFilters[lid]) {
74            var originalFilter = null;
75            try {
76                originalFilter = map.getFilter(lid);
77            } catch (e) {
78                // Layer may not exist yet
79            }
80            layerState.interactiveFilters[lid] = {
81                originalFilter: originalFilter,
82                legendFilters: {}
83            };
84        }
85    });
86
87    // Determine filter column - use provided or auto-detect from first available layer
88    var filterColumn = config.filterColumn;
89    if (!filterColumn) {
90        for (var i = 0; i < layerIds.length; i++) {
91            filterColumn = detectFilterColumn(map, layerIds[i]);
92            if (filterColumn) break;
93        }
94    }
95
96    if (!filterColumn) {
97        console.warn(
98            "Could not determine filter column for interactive legend. " +
99                "Please provide filter_column parameter."
100        );
101        return;
102    }
103
104    // Store normalized layerIds in config for use by init functions
105    config._layerIds = layerIds;
106
107    // Store config for later reference
108    legendElement._interactivityConfig = {
109        legendId: config.legendId,
110        layerIds: layerIds,
111        type: config.type,
112        values: config.values,
113        colors: config.colors,
114        filterColumn: filterColumn,
115        mapId: mapId
116    };
117
118    if (config.type === "categorical") {
119        initCategoricalLegend(map, mapId, legendElement, filterColumn, config);
120    } else if (config.type === "continuous") {
121        initContinuousLegend(map, mapId, legendElement, filterColumn, config);
122    }
123}
124
125/**
126 * Detect filter column from layer's paint expression
127 * @param {Object} map - The map instance
128 * @param {string} layerId - The layer ID
129 * @returns {string|null} The detected column name or null
130 */
131function detectFilterColumn(map, layerId) {
132    var layer = null;
133    try {
134        layer = map.getLayer(layerId);
135    } catch (e) {
136        return null;
137    }
138    if (!layer) return null;
139
140    // Check common paint properties for expressions
141    var paintProps = [
142        "fill-color",
143        "circle-color",
144        "line-color",
145        "fill-opacity",
146        "circle-radius",
147        "line-width",
148        "fill-extrusion-color"
149    ];
150
151    for (var i = 0; i < paintProps.length; i++) {
152        var prop = paintProps[i];
153        try {
154            var paintValue = map.getPaintProperty(layerId, prop);
155            if (paintValue && Array.isArray(paintValue)) {
156                var column = parseExpressionForColumn(paintValue);
157                if (column) return column;
158            }
159        } catch (e) {
160            // Property may not exist for this layer type
161        }
162    }
163
164    return null;
165}
166
167/**
168 * Parse expression to extract column name
169 * Handles: match, interpolate, step, case expressions
170 * @param {Array} expr - The expression array
171 * @returns {string|null} The column name or null
172 */
173function parseExpressionForColumn(expr) {
174    if (!Array.isArray(expr) || expr.length < 2) return null;
175
176    var type = expr[0];
177
178    // match: ["match", ["get", "column"], ...]
179    if (type === "match" && Array.isArray(expr[1])) {
180        if (expr[1][0] === "get" && typeof expr[1][1] === "string") {
181            return expr[1][1];
182        }
183    }
184
185    // interpolate: ["interpolate", ["linear"], ["get", "column"], ...]
186    if (type === "interpolate" && expr.length >= 3) {
187        var getExpr = expr[2];
188        if (Array.isArray(getExpr) && getExpr[0] === "get") {
189            return getExpr[1];
190        }
191    }
192
193    // step: ["step", ["get", "column"], base, ...]
194    if (type === "step" && Array.isArray(expr[1])) {
195        if (expr[1][0] === "get" && typeof expr[1][1] === "string") {
196            return expr[1][1];
197        }
198    }
199
200    // case with na_color wrapper: ["case", ["==", ["get", "column"], null], na_color, inner_expr]
201    if (type === "case" && expr.length >= 4) {
202        // Check inner expression (last non-default element)
203        for (var i = 3; i < expr.length; i++) {
204            if (Array.isArray(expr[i])) {
205                var result = parseExpressionForColumn(expr[i]);
206                if (result) return result;
207            }
208        }
209    }
210
211    return null;
212}
213
214/**
215 * Initialize categorical legend interactivity
216 */
217function initCategoricalLegend(map, mapId, legendElement, filterColumn, config) {
218    var items = legendElement.querySelectorAll(".legend-item");
219    var layerIds = config._layerIds;
220
221    // Use filterValues for actual filtering, values for display
222    var filterValues = config.filterValues || config.values;
223    var displayValues = config.values;
224    var breaks = config.breaks; // For range-based filtering (from classifications)
225
226    // Track enabled state by index (to map display values to filter values)
227    var numCategories = displayValues.length;
228    var enabledIndices = new Set();
229    for (var i = 0; i < numCategories; i++) {
230        enabledIndices.add(i);
231    }
232
233    var layerState = window._mapglLayerState[mapId];
234
235    // Store category state for each layer
236    layerIds.forEach(function(lid) {
237        var interactiveState = layerState.interactiveFilters[lid];
238        interactiveState.enabledIndices = enabledIndices;
239        interactiveState.filterValues = filterValues;
240        interactiveState.breaks = breaks;
241        interactiveState.filterColumn = filterColumn;
242    });
243
244    // Store original colors for each item
245    var originalColors = {};
246
247    // Add click handlers to each item
248    items.forEach(function (item, idx) {
249        var displayValue = item.getAttribute("data-value") || String(displayValues[idx]);
250        item.setAttribute("data-value", displayValue);
251        item.setAttribute("data-index", idx);
252        item.setAttribute("data-enabled", "true");
253
254        // Store original color - try multiple selectors for different patch types
255        var colorSpan = item.querySelector(".legend-color") ||
256                        item.querySelector(".legend-shape-svg") ||
257                        item.querySelector(".legend-shape-custom");
258        if (colorSpan) {
259            originalColors[idx] = colorSpan.style.backgroundColor ||
260                                  colorSpan.getAttribute("fill") ||
261                                  config.colors[idx];
262        } else {
263            // Fallback to config colors
264            originalColors[idx] = config.colors[idx];
265        }
266
267        item.addEventListener("click", function (e) {
268            e.preventDefault();
269            e.stopPropagation();
270
271            var currentlyEnabled = item.getAttribute("data-enabled") === "true";
272            var newState = !currentlyEnabled;
273
274            item.setAttribute("data-enabled", String(newState));
275
276            if (newState) {
277                enabledIndices.add(idx);
278                // Restore original color
279                if (colorSpan) {
280                    colorSpan.style.backgroundColor = originalColors[idx];
281                }
282            } else {
283                enabledIndices.delete(idx);
284                // Grey out
285                if (colorSpan) {
286                    colorSpan.style.backgroundColor = "#cccccc";
287                }
288            }
289
290            // Apply filter to all associated layers
291            layerIds.forEach(function(lid) {
292                var interactiveState = layerState.interactiveFilters[lid];
293                if (breaks && breaks.length > 0) {
294                    applyRangeBasedCategoricalFilter(
295                        map,
296                        mapId,
297                        lid,
298                        filterColumn,
299                        enabledIndices,
300                        breaks,
301                        interactiveState.originalFilter
302                    );
303                } else {
304                    applyCategoricalFilter(
305                        map,
306                        mapId,
307                        lid,
308                        filterColumn,
309                        enabledIndices,
310                        filterValues,
311                        interactiveState.originalFilter
312                    );
313                }
314            });
315
316            // Update reset button visibility
317            updateResetButton(legendElement, enabledIndices.size < numCategories);
318
319            // Send to Shiny if applicable
320            if (typeof HTMLWidgets !== "undefined" && HTMLWidgets.shinyMode) {
321                // Get enabled filter values for Shiny
322                var enabledFilterValues = [];
323                enabledIndices.forEach(function(i) {
324                    enabledFilterValues.push(filterValues[i]);
325                });
326                Shiny.setInputValue(mapId + "_legend_filter", {
327                    legendId: config.legendId,
328                    layerId: layerIds.length === 1 ? layerIds[0] : layerIds,
329                    type: "categorical",
330                    column: filterColumn,
331                    enabledValues: enabledFilterValues,
332                    timestamp: Date.now()
333                });
334            }
335        });
336    });
337
338    // Add reset button
339    addResetButton(legendElement, function () {
340        items.forEach(function (item, i) {
341            item.setAttribute("data-enabled", "true");
342            var colorSpan = item.querySelector(".legend-color") ||
343                            item.querySelector(".legend-shape-svg") ||
344                            item.querySelector(".legend-shape-custom");
345            if (colorSpan && originalColors[i] !== undefined) {
346                colorSpan.style.backgroundColor = originalColors[i];
347            }
348        });
349        enabledIndices.clear();
350        for (var i = 0; i < numCategories; i++) {
351            enabledIndices.add(i);
352        }
353
354        // Reset filter to original for all layers
355        layerIds.forEach(function(lid) {
356            var interactiveState = layerState.interactiveFilters[lid];
357            if (interactiveState.originalFilter) {
358                map.setFilter(lid, interactiveState.originalFilter);
359            } else {
360                map.setFilter(lid, null);
361            }
362            layerState.filters[lid] = interactiveState.originalFilter;
363        });
364
365        updateResetButton(legendElement, false);
366
367        // Send to Shiny
368        if (typeof HTMLWidgets !== "undefined" && HTMLWidgets.shinyMode) {
369            Shiny.setInputValue(mapId + "_legend_filter", {
370                legendId: config.legendId,
371                layerId: layerIds.length === 1 ? layerIds[0] : layerIds,
372                type: "categorical",
373                column: filterColumn,
374                enabledValues: filterValues.slice(),
375                timestamp: Date.now()
376            });
377        }
378    });
379}
380
381/**
382 * Initialize continuous legend interactivity with gradient-overlay handles
383 * Features:
384 * - Vertical line handles positioned on the gradient bar
385 * - Semi-transparent overlays on unselected regions
386 * - Draggable middle region to pan the selection
387 * - Piecewise linear interpolation for non-linear color ramps
388 */
389function initContinuousLegend(map, mapId, legendElement, filterColumn, config) {
390    var values = config.values.map(Number);
391    var minValue = Math.min.apply(null, values);
392    var maxValue = Math.max.apply(null, values);
393    var layerIds = config._layerIds;
394
395    var layerState = window._mapglLayerState[mapId];
396
397    // Store range state for each layer
398    layerIds.forEach(function(lid) {
399        var interactiveState = layerState.interactiveFilters[lid];
400        interactiveState.rangeMin = minValue;
401        interactiveState.rangeMax = maxValue;
402        interactiveState.originalMin = minValue;
403        interactiveState.originalMax = maxValue;
404        interactiveState.filterColumn = filterColumn;
405        interactiveState.values = values;
406    });
407
408    // Find the gradient bar
409    var gradientBar = legendElement.querySelector(".legend-gradient");
410    if (!gradientBar) {
411        console.warn("No gradient bar found in legend for interactive continuous legend");
412        return;
413    }
414
415    // Create the interactive overlay container
416    var overlayContainer = document.createElement("div");
417    overlayContainer.className = "legend-gradient-overlay-container";
418
419    // Create left ghost overlay (unselected region)
420    var leftOverlay = document.createElement("div");
421    leftOverlay.className = "legend-gradient-ghost legend-gradient-ghost-left";
422
423    // Create right ghost overlay (unselected region)
424    var rightOverlay = document.createElement("div");
425    rightOverlay.className = "legend-gradient-ghost legend-gradient-ghost-right";
426
427    // Create left handle (min)
428    var leftHandle = document.createElement("div");
429    leftHandle.className = "legend-gradient-handle legend-gradient-handle-left";
430    leftHandle.title = "Drag to set minimum value";
431
432    // Create right handle (max)
433    var rightHandle = document.createElement("div");
434    rightHandle.className = "legend-gradient-handle legend-gradient-handle-right";
435    rightHandle.title = "Drag to set maximum value";
436
437    // Create middle draggable region
438    var middleRegion = document.createElement("div");
439    middleRegion.className = "legend-gradient-middle";
440    middleRegion.title = "Drag to pan the selected range";
441
442    overlayContainer.appendChild(leftOverlay);
443    overlayContainer.appendChild(rightOverlay);
444    overlayContainer.appendChild(leftHandle);
445    overlayContainer.appendChild(rightHandle);
446    overlayContainer.appendChild(middleRegion);
447
448    // Insert overlay container into gradient bar's parent (positioned over gradient)
449    gradientBar.style.position = "relative";
450    gradientBar.appendChild(overlayContainer);
451
452    // Create current range display below gradient
453    var rangeDisplay = document.createElement("div");
454    rangeDisplay.className = "legend-range-display";
455    rangeDisplay.textContent = formatValue(minValue) + " — " + formatValue(maxValue);
456
457    // Insert range display after gradient, before labels
458    var labelsEl = legendElement.querySelector(".legend-labels");
459    if (labelsEl) {
460        labelsEl.parentNode.insertBefore(rangeDisplay, labelsEl);
461    } else {
462        gradientBar.parentNode.insertBefore(rangeDisplay, gradientBar.nextSibling);
463    }
464
465    // Current selection state (as percentages 0-100)
466    var selectionState = {
467        leftPercent: 0,
468        rightPercent: 100
469    };
470
471    /**
472     * Convert position percentage (0-100) to data value
473     * Uses piecewise linear interpolation for non-linear color ramps
474     */
475    function positionToValue(percent) {
476        if (values.length < 2) return values[0] || 0;
477
478        // Clamp percent
479        percent = Math.max(0, Math.min(100, percent));
480
481        var segmentCount = values.length - 1;
482        var segmentWidth = 100 / segmentCount;
483        var segmentIndex = Math.min(
484            Math.floor(percent / segmentWidth),
485            segmentCount - 1
486        );
487        var localPercent = percent - segmentIndex * segmentWidth;
488        var localFraction = localPercent / segmentWidth;
489
490        var startVal = values[segmentIndex];
491        var endVal = values[segmentIndex + 1];
492
493        return startVal + localFraction * (endVal - startVal);
494    }
495
496    /**
497     * Convert data value to position percentage (0-100)
498     * Inverse of positionToValue
499     */
500    function valueToPosition(value) {
501        if (values.length < 2) return 0;
502
503        // Find which segment this value falls in
504        for (var i = 0; i < values.length - 1; i++) {
505            var startVal = values[i];
506            var endVal = values[i + 1];
507            var minSegVal = Math.min(startVal, endVal);
508            var maxSegVal = Math.max(startVal, endVal);
509
510            if (value >= minSegVal && value <= maxSegVal) {
511                var segmentCount = values.length - 1;
512                var segmentWidth = 100 / segmentCount;
513                var localFraction = (value - startVal) / (endVal - startVal);
514                return i * segmentWidth + localFraction * segmentWidth;
515            }
516        }
517
518        // Fallback: linear interpolation across full range
519        return ((value - minValue) / (maxValue - minValue)) * 100;
520    }
521
522    /**
523     * Update visual elements based on current selection
524     */
525    function updateVisuals() {
526        var left = selectionState.leftPercent;
527        var right = selectionState.rightPercent;
528
529        // Update ghost overlays
530        leftOverlay.style.width = left + "%";
531        rightOverlay.style.left = right + "%";
532        rightOverlay.style.width = (100 - right) + "%";
533
534        // Update handles
535        leftHandle.style.left = left + "%";
536        rightHandle.style.left = right + "%";
537
538        // Update middle region
539        middleRegion.style.left = left + "%";
540        middleRegion.style.width = (right - left) + "%";
541
542        // Update range display with actual values
543        var minVal = positionToValue(left);
544        var maxVal = positionToValue(right);
545        rangeDisplay.textContent = formatValue(minVal) + " — " + formatValue(maxVal);
546    }
547
548    /**
549     * Apply filter based on current selection
550     */
551    var filterTimeout;
552    function applyFilterDebounced() {
553        clearTimeout(filterTimeout);
554        filterTimeout = setTimeout(function () {
555            var minVal = positionToValue(selectionState.leftPercent);
556            var maxVal = positionToValue(selectionState.rightPercent);
557
558            // Check if at full range (within tolerance) - if so, clear filter instead
559            // This handles cases where legend breaks are rounded but data has more precision
560            var isAtFullRange =
561                selectionState.leftPercent <= 0.5 &&
562                selectionState.rightPercent >= 99.5;
563
564            // Apply to all associated layers
565            layerIds.forEach(function(lid) {
566                var interactiveState = layerState.interactiveFilters[lid];
567                interactiveState.rangeMin = minVal;
568                interactiveState.rangeMax = maxVal;
569
570                if (isAtFullRange) {
571                    map.setFilter(lid, interactiveState.originalFilter);
572                    layerState.filters[lid] = interactiveState.originalFilter;
573                } else {
574                    applyRangeFilter(
575                        map,
576                        mapId,
577                        lid,
578                        filterColumn,
579                        minVal,
580                        maxVal,
581                        interactiveState.originalFilter
582                    );
583                }
584            });
585
586            // Update reset button visibility
587            var hasFilter = !isAtFullRange;
588            updateResetButton(legendElement, hasFilter);
589
590            // Send to Shiny if applicable
591            if (typeof HTMLWidgets !== "undefined" && HTMLWidgets.shinyMode) {
592                Shiny.setInputValue(mapId + "_legend_filter", {
593                    legendId: config.legendId,
594                    layerId: layerIds.length === 1 ? layerIds[0] : layerIds,
595                    type: "continuous",
596                    column: filterColumn,
597                    range: [minVal, maxVal],
598                    timestamp: Date.now()
599                });
600            }
601        }, 50);
602    }
603
604    /**
605     * Get mouse/touch position as percentage of gradient width
606     */
607    function getPositionPercent(e) {
608        var rect = gradientBar.getBoundingClientRect();
609        var clientX = e.touches ? e.touches[0].clientX : e.clientX;
610        var x = clientX - rect.left;
611        var percent = (x / rect.width) * 100;
612        return Math.max(0, Math.min(100, percent));
613    }
614
615    // Drag state
616    var dragState = {
617        active: false,
618        target: null, // 'left', 'right', or 'middle'
619        startX: 0,
620        startLeftPercent: 0,
621        startRightPercent: 0
622    };
623
624    /**
625     * Handle drag start
626     */
627    function onDragStart(e, target) {
628        e.preventDefault();
629        dragState.active = true;
630        dragState.target = target;
631        dragState.startX = e.touches ? e.touches[0].clientX : e.clientX;
632        dragState.startLeftPercent = selectionState.leftPercent;
633        dragState.startRightPercent = selectionState.rightPercent;
634
635        document.addEventListener("mousemove", onDragMove);
636        document.addEventListener("mouseup", onDragEnd);
637        document.addEventListener("touchmove", onDragMove);
638        document.addEventListener("touchend", onDragEnd);
639
640        // Add dragging class for visual feedback
641        overlayContainer.classList.add("dragging");
642    }
643
644    /**
645     * Handle drag movement
646     */
647    function onDragMove(e) {
648        if (!dragState.active) return;
649
650        var percent = getPositionPercent(e);
651
652        if (dragState.target === "left") {
653            // Move left handle
654            var newLeft = Math.max(
655                0,
656                Math.min(selectionState.rightPercent - 2, percent)
657            );
658            selectionState.leftPercent = newLeft;
659        } else if (dragState.target === "right") {
660            // Move right handle
661            var newRight = Math.min(
662                100,
663                Math.max(selectionState.leftPercent + 2, percent)
664            );
665            selectionState.rightPercent = newRight;
666        } else if (dragState.target === "middle") {
667            // Pan both handles together
668            var rangeWidth =
669                dragState.startRightPercent - dragState.startLeftPercent;
670            var currentX = e.touches ? e.touches[0].clientX : e.clientX;
671            var rect = gradientBar.getBoundingClientRect();
672            var deltaX = currentX - dragState.startX;
673            var deltaPct = (deltaX / rect.width) * 100;
674
675            var newLeft = dragState.startLeftPercent + deltaPct;
676            var newRight = dragState.startRightPercent + deltaPct;
677
678            // Clamp to bounds
679            if (newLeft < 0) {
680                newLeft = 0;
681                newRight = rangeWidth;
682            }
683            if (newRight > 100) {
684                newRight = 100;
685                newLeft = 100 - rangeWidth;
686            }
687
688            selectionState.leftPercent = newLeft;
689            selectionState.rightPercent = newRight;
690        }
691
692        updateVisuals();
693        // Visual preview only during drag - filter applied on drag end
694    }
695
696    /**
697     * Handle drag end - apply filter on release
698     */
699    function onDragEnd() {
700        if (!dragState.active) return;
701
702        dragState.active = false;
703        dragState.target = null;
704
705        document.removeEventListener("mousemove", onDragMove);
706        document.removeEventListener("mouseup", onDragEnd);
707        document.removeEventListener("touchmove", onDragMove);
708        document.removeEventListener("touchend", onDragEnd);
709
710        overlayContainer.classList.remove("dragging");
711
712        // Apply filter now that drag is complete
713        applyFilterDebounced();
714    }
715
716    // Attach event listeners
717    leftHandle.addEventListener("mousedown", function (e) {
718        onDragStart(e, "left");
719    });
720    leftHandle.addEventListener("touchstart", function (e) {
721        onDragStart(e, "left");
722    });
723
724    rightHandle.addEventListener("mousedown", function (e) {
725        onDragStart(e, "right");
726    });
727    rightHandle.addEventListener("touchstart", function (e) {
728        onDragStart(e, "right");
729    });
730
731    middleRegion.addEventListener("mousedown", function (e) {
732        onDragStart(e, "middle");
733    });
734    middleRegion.addEventListener("touchstart", function (e) {
735        onDragStart(e, "middle");
736    });
737
738    // Initial visual update
739    updateVisuals();
740
741    // Add reset button
742    addResetButton(legendElement, function () {
743        selectionState.leftPercent = 0;
744        selectionState.rightPercent = 100;
745        updateVisuals();
746
747        // Reset filter to original for all layers
748        layerIds.forEach(function(lid) {
749            var interactiveState = layerState.interactiveFilters[lid];
750            interactiveState.rangeMin = minValue;
751            interactiveState.rangeMax = maxValue;
752
753            if (interactiveState.originalFilter) {
754                map.setFilter(lid, interactiveState.originalFilter);
755            } else {
756                map.setFilter(lid, null);
757            }
758            layerState.filters[lid] = interactiveState.originalFilter;
759        });
760
761        updateResetButton(legendElement, false);
762
763        // Send to Shiny
764        if (typeof HTMLWidgets !== "undefined" && HTMLWidgets.shinyMode) {
765            Shiny.setInputValue(mapId + "_legend_filter", {
766                legendId: config.legendId,
767                layerId: layerIds.length === 1 ? layerIds[0] : layerIds,
768                type: "continuous",
769                column: filterColumn,
770                range: [minValue, maxValue],
771                timestamp: Date.now()
772            });
773        }
774    });
775}
776
777/**
778 * Apply categorical filter to layer
779 * @param enabledIndices - Set of enabled indices
780 * @param filterValues - Array of actual data values to filter on
781 */
782function applyCategoricalFilter(
783    map,
784    mapId,
785    layerId,
786    column,
787    enabledIndices,
788    filterValues,
789    originalFilter
790) {
791    var layerState = window._mapglLayerState[mapId];
792
793    var interactiveFilter;
794    if (enabledIndices.size === 0) {
795        // No categories enabled - hide all features
796        interactiveFilter = ["==", ["get", column], "__IMPOSSIBLE_VALUE__"];
797    } else {
798        // Build array of enabled filter values - preserve original types
799        var enabledValues = [];
800        enabledIndices.forEach(function(i) {
801            if (filterValues[i] !== undefined) {
802                // Don't convert to String - preserve numeric types for proper matching
803                enabledValues.push(filterValues[i]);
804            }
805        });
806
807        // Create "match" filter for enabled categories
808        // Format: ["match", ["get", column], [values], true, false]
809        interactiveFilter = [
810            "match",
811            ["get", column],
812            enabledValues,
813            true,
814            false
815        ];
816    }
817
818    // Combine with original filter if exists
819    var finalFilter = combineFilters(originalFilter, interactiveFilter);
820
821    map.setFilter(layerId, finalFilter);
822    layerState.filters[layerId] = finalFilter;
823}
824
825/**
826 * Apply range-based categorical filter (for binned/quantile classifications)
827 * @param enabledIndices - Set of enabled bin indices
828 * @param breaks - Array of break values [min, break1, break2, ..., max]
829 */
830function applyRangeBasedCategoricalFilter(
831    map,
832    mapId,
833    layerId,
834    column,
835    enabledIndices,
836    breaks,
837    originalFilter
838) {
839    var layerState = window._mapglLayerState[mapId];
840
841    // Calculate epsilon for floating-point precision and rounding issues
842    // Use a larger epsilon (0.1% of range) to account for rounded legend breaks
843    var overallMin = breaks[0];
844    var overallMax = breaks[breaks.length - 1];
845    var range = Math.abs(overallMax - overallMin);
846    var epsilon = range > 0 ? range * 0.001 : 0.001;
847
848    var interactiveFilter;
849    if (enabledIndices.size === 0) {
850        // No categories enabled - hide all features
851        interactiveFilter = ["==", ["get", column], "__IMPOSSIBLE_VALUE__"];
852    } else {
853        // Build "any" filter with range conditions for each enabled bin
854        var rangeConditions = [];
855        enabledIndices.forEach(function(i) {
856            if (i < breaks.length - 1) {
857                var minVal = breaks[i];
858                var maxVal = breaks[i + 1];
859                // Each bin: value >= min AND value < max (except last bin uses <=)
860                var isLastBin = (i === breaks.length - 2);
861                // Apply epsilon adjustment for edge values to handle floating-point precision
862                var filterMin = (i === 0) ? minVal - epsilon : minVal;
863                var filterMax = isLastBin ? maxVal + epsilon : maxVal;
864                var binCondition = [
865                    "all",
866                    [">=", ["get", column], filterMin],
867                    isLastBin ? ["<=", ["get", column], filterMax] : ["<", ["get", column], filterMax]
868                ];
869                rangeConditions.push(binCondition);
870            }
871        });
872
873        if (rangeConditions.length === 1) {
874            interactiveFilter = rangeConditions[0];
875        } else {
876            interactiveFilter = ["any"].concat(rangeConditions);
877        }
878    }
879
880    // Combine with original filter if exists
881    var finalFilter = combineFilters(originalFilter, interactiveFilter);
882
883    map.setFilter(layerId, finalFilter);
884    layerState.filters[layerId] = finalFilter;
885}
886
887/**
888 * Apply range filter to layer
889 */
890function applyRangeFilter(
891    map,
892    mapId,
893    layerId,
894    column,
895    min,
896    max,
897    originalFilter
898) {
899    var layerState = window._mapglLayerState[mapId];
900
901    // Add epsilon to handle floating-point precision and rounding issues
902    // Use a larger epsilon (0.1% of range) to account for rounded legend breaks
903    // that may not exactly match data values
904    var range = Math.abs(max - min);
905    var epsilon = range > 0 ? range * 0.001 : 0.001;
906    var filterMin = min - epsilon;
907    var filterMax = max + epsilon;
908
909    var interactiveFilter = [
910        "all",
911        [">=", ["get", column], filterMin],
912        ["<=", ["get", column], filterMax]
913    ];
914
915    // Combine with original filter if exists
916    var finalFilter = combineFilters(originalFilter, interactiveFilter);
917
918    map.setFilter(layerId, finalFilter);
919    layerState.filters[layerId] = finalFilter;
920}
921
922/**
923 * Combine existing filter with interactive filter
924 */
925function combineFilters(existingFilter, interactiveFilter) {
926    if (!existingFilter) {
927        return interactiveFilter;
928    }
929    // Wrap both in "all"
930    return ["all", existingFilter, interactiveFilter];
931}
932
933/**
934 * Add reset button to legend
935 */
936function addResetButton(legendElement, resetCallback) {
937    if (legendElement.querySelector(".legend-reset-btn")) return;
938
939    var resetBtn = document.createElement("button");
940    resetBtn.className = "legend-reset-btn";
941    resetBtn.textContent = "Reset Filter";
942    resetBtn.addEventListener("click", function (e) {
943        e.preventDefault();
944        e.stopPropagation();
945        resetCallback();
946    });
947    legendElement.appendChild(resetBtn);
948}
949
950/**
951 * Update reset button visibility
952 */
953function updateResetButton(legendElement, show) {
954    var resetBtn = legendElement.querySelector(".legend-reset-btn");
955    if (resetBtn) {
956        resetBtn.classList.toggle("visible", show);
957    }
958}
959
960/**
961 * Format numeric value for display
962 */
963function formatValue(value) {
964    if (value === null || value === undefined || isNaN(value)) {
965        return String(value);
966    }
967    var absValue = Math.abs(value);
968    if (absValue >= 1000000) {
969        return (value / 1000000).toFixed(1) + "M";
970    } else if (absValue >= 1000) {
971        return (value / 1000).toFixed(1) + "K";
972    } else if (Number.isInteger(value)) {
973        return value.toString();
974    } else {
975        return value.toFixed(2);
976    }
977}
978
979/**
980 * Initialize draggable functionality for legends
981 * Called automatically when legends are rendered
982 * @param {HTMLElement} container - The map container element
983 */
984function initializeDraggableLegends(container) {
985    var legends = container.querySelectorAll('.mapboxgl-legend[data-draggable="true"]');
986    legends.forEach(function(legend) {
987        // Skip if already initialized
988        if (legend._draggableInitialized) return;
989        legend._draggableInitialized = true;
990
991        makeLegendDraggable(legend);
992    });
993}
994
995/**
996 * Make a single legend element draggable
997 * @param {HTMLElement} legend - The legend element
998 */
999function makeLegendDraggable(legend) {
1000    var isDragging = false;
1001    var startX, startY;
1002    var startLeft, startTop;
1003
1004    // Add draggable class for styling
1005    legend.classList.add('legend-draggable');
1006
1007    // Get the map container (parent of legend)
1008    var mapContainer = legend.closest('.mapboxgl-map, .maplibregl-map') || legend.parentElement;
1009
1010    function onMouseDown(e) {
1011        // Don't start drag if clicking on interactive elements (including slider handles)
1012        if (e.target.closest('.legend-item, .legend-reset-btn, .continuous-slider-container, .legend-gradient-handle, .legend-gradient-middle, .legend-gradient-overlay-container, input, button')) {
1013            return;
1014        }
1015
1016        isDragging = true;
1017        legend.classList.add('legend-dragging');
1018
1019        startX = e.clientX;
1020        startY = e.clientY;
1021
1022        // Get current position
1023        var rect = legend.getBoundingClientRect();
1024        var containerRect = mapContainer.getBoundingClientRect();
1025
1026        // Calculate position relative to container
1027        startLeft = rect.left - containerRect.left;
1028        startTop = rect.top - containerRect.top;
1029
1030        // Switch to absolute positioning with explicit coordinates
1031        legend.style.position = 'absolute';
1032        legend.style.left = startLeft + 'px';
1033        legend.style.top = startTop + 'px';
1034        legend.style.right = 'auto';
1035        legend.style.bottom = 'auto';
1036
1037        e.preventDefault();
1038        e.stopPropagation();
1039
1040        document.addEventListener('mousemove', onMouseMove);
1041        document.addEventListener('mouseup', onMouseUp);
1042    }
1043
1044    function onMouseMove(e) {
1045        if (!isDragging) return;
1046
1047        var deltaX = e.clientX - startX;
1048        var deltaY = e.clientY - startY;
1049
1050        var newLeft = startLeft + deltaX;
1051        var newTop = startTop + deltaY;
1052
1053        // Constrain to container bounds
1054        var containerRect = mapContainer.getBoundingClientRect();
1055        var legendRect = legend.getBoundingClientRect();
1056
1057        var maxLeft = containerRect.width - legendRect.width;
1058        var maxTop = containerRect.height - legendRect.height;
1059
1060        newLeft = Math.max(0, Math.min(newLeft, maxLeft));
1061        newTop = Math.max(0, Math.min(newTop, maxTop));
1062
1063        legend.style.left = newLeft + 'px';
1064        legend.style.top = newTop + 'px';
1065
1066        e.preventDefault();
1067    }
1068
1069    function onMouseUp(e) {
1070        if (!isDragging) return;
1071
1072        isDragging = false;
1073        legend.classList.remove('legend-dragging');
1074
1075        document.removeEventListener('mousemove', onMouseMove);
1076        document.removeEventListener('mouseup', onMouseUp);
1077    }
1078
1079    // Add touch support
1080    function onTouchStart(e) {
1081        if (e.touches.length !== 1) return;
1082
1083        var touch = e.touches[0];
1084        // Don't start drag if touching interactive elements (including slider handles)
1085        if (e.target.closest('.legend-item, .legend-reset-btn, .continuous-slider-container, .legend-gradient-handle, .legend-gradient-middle, .legend-gradient-overlay-container, input, button')) {
1086            return;
1087        }
1088
1089        isDragging = true;
1090        legend.classList.add('legend-dragging');
1091
1092        startX = touch.clientX;
1093        startY = touch.clientY;
1094
1095        var rect = legend.getBoundingClientRect();
1096        var containerRect = mapContainer.getBoundingClientRect();
1097
1098        startLeft = rect.left - containerRect.left;
1099        startTop = rect.top - containerRect.top;
1100
1101        legend.style.position = 'absolute';
1102        legend.style.left = startLeft + 'px';
1103        legend.style.top = startTop + 'px';
1104        legend.style.right = 'auto';
1105        legend.style.bottom = 'auto';
1106
1107        e.preventDefault();
1108    }
1109
1110    function onTouchMove(e) {
1111        if (!isDragging || e.touches.length !== 1) return;
1112
1113        var touch = e.touches[0];
1114        var deltaX = touch.clientX - startX;
1115        var deltaY = touch.clientY - startY;
1116
1117        var newLeft = startLeft + deltaX;
1118        var newTop = startTop + deltaY;
1119
1120        var containerRect = mapContainer.getBoundingClientRect();
1121        var legendRect = legend.getBoundingClientRect();
1122
1123        var maxLeft = containerRect.width - legendRect.width;
1124        var maxTop = containerRect.height - legendRect.height;
1125
1126        newLeft = Math.max(0, Math.min(newLeft, maxLeft));
1127        newTop = Math.max(0, Math.min(newTop, maxTop));
1128
1129        legend.style.left = newLeft + 'px';
1130        legend.style.top = newTop + 'px';
1131
1132        e.preventDefault();
1133    }
1134
1135    function onTouchEnd(e) {
1136        if (!isDragging) return;
1137
1138        isDragging = false;
1139        legend.classList.remove('legend-dragging');
1140    }
1141
1142    legend.addEventListener('mousedown', onMouseDown);
1143    legend.addEventListener('touchstart', onTouchStart, { passive: false });
1144    legend.addEventListener('touchmove', onTouchMove, { passive: false });
1145    legend.addEventListener('touchend', onTouchEnd);
1146}

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.