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.