From 9b8752eef5b1a908f1c34b36104374c522fbb7d2 Mon Sep 17 00:00:00 2001 From: zyphlar Date: Thu, 15 Oct 2026 12:44:48 -0700 Subject: [PATCH] Grayscale OSM base tiles, mobile-friendly dashboard and map with touch multi-select and box select, wider road click radius, and strip internal properties from JOSM export --- web/app.js | 5 +- web/static/map-ui.css | 38 ++++- web/static/map-ui.js | 8 +- web/static/map.js | 310 ++++++++++++++++++++++++++++----------- web/templates/index.html | 129 +++++++++++++++- web/templates/map.html | 69 ++++++++- 6 files changed, 457 insertions(+), 102 deletions(-) diff --git a/web/app.js b/web/app.js index 792897b..2b20048 100644 --- a/web/app.js +++ b/web/app.js @@ -17,9 +17,8 @@ let layerOrder = ['diff', 'osm', 'county']; // Default layer order (top to botto function initMap() { map = L.map('map').setView([28.7, -81.7], 12); - L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France', - subdomains: 'abc', + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', maxNativeZoom: 19, maxZoom: 20 }).addTo(map); diff --git a/web/static/map-ui.css b/web/static/map-ui.css index 39e992e..9dff248 100644 --- a/web/static/map-ui.css +++ b/web/static/map-ui.css @@ -30,6 +30,7 @@ body { border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); min-width: 175px; + max-width: 280px; max-height: calc(100vh - 20px); overflow-y: auto; } @@ -154,14 +155,17 @@ body { position: fixed; top: 0; right: -100%; - width: 80%; - max-width: 320px; + width: 85%; + min-width: 0; + max-width: 300px; height: 100vh; + height: 100dvh; + max-height: none; overflow-y: auto; transition: right 0.3s ease; border-radius: 0; box-shadow: -2px 0 10px rgba(0,0,0,0.2); - padding-top: 50px; + padding-top: 64px; } .controls.open { @@ -173,7 +177,28 @@ body { } .controls label { - font-size: 12px; + font-size: 13px; + margin: 8px 0; + } + + .controls input[type="checkbox"] { + width: 18px; + height: 18px; + } + + .controls select { + font-size: 14px; + padding: 6px; + } + + .controls button { + padding: 10px; + font-size: 14px; + } + + .btn-back { + padding: 9px 10px; + font-size: 14px; } } @@ -205,3 +230,8 @@ body { text-align: center; } .btn-back:hover { background: #2d2d4e; color: #fff; } + +/* Mute the base map so colored overlays (e.g. lime green diffs) stand out. */ +.base-tiles { + filter: grayscale(100%) brightness(1.1) contrast(0.95); +} diff --git a/web/static/map-ui.js b/web/static/map-ui.js index 902904f..2b9c0d1 100644 --- a/web/static/map-ui.js +++ b/web/static/map-ui.js @@ -5,11 +5,11 @@ function initBaseMap(elementId, view, zoom) { const map = L.map(elementId).setView(view || [28.7, -81.7], 12); - L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France', - subdomains: 'abc', + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', maxNativeZoom: 19, - maxZoom: 20 + maxZoom: 20, + className: 'base-tiles' }).addTo(map); if (zoom) map.setZoom(zoom); diff --git a/web/static/map.js b/web/static/map.js index 99f9a39..1e841d9 100644 --- a/web/static/map.js +++ b/web/static/map.js @@ -22,6 +22,90 @@ let dragStartPoint = null; let selectionBox = null; let multiSelectMode = false; +// Render vector layers to canvas with a hit tolerance (extra pixels around +// each line that still count as a click/tap) so 3px road lines are easy to +// hit. Fingers get a wider margin than a mouse pointer. +const IS_TOUCH = window.matchMedia('(pointer: coarse)').matches; +const HIT_TOLERANCE = IS_TOUCH ? 16 : 8; + +// Pixel distance from a canvas-rendered layer's edge to a layer point. +function distanceToLayer(layer, p) { + if (layer instanceof L.CircleMarker) { + return Math.max(0, p.distanceTo(layer._point) - layer._radius); + } + const closed = layer instanceof L.Polygon; + let min = Infinity; + (layer._parts || []).forEach(part => { + if (part.length === 1) min = Math.min(min, p.distanceTo(part[0])); + for (let i = 1; i < part.length; i++) { + min = Math.min(min, L.LineUtil.pointToSegmentDistance(p, part[i - 1], part[i])); + } + if (closed && part.length > 2) { + min = Math.min(min, L.LineUtil.pointToSegmentDistance(p, part[part.length - 1], part[0])); + } + }); + // A hit inside a polygon's fill but away from its outline ranks behind + // any line that is within tolerance. + if (closed && min > layer._clickTolerance()) return HIT_TOLERANCE; + return Math.max(0, min - (layer.options.stroke ? layer.options.weight / 2 : 0)); +} + +// Leaflet's canvas renderer hands a click/hover to whichever layer within +// `tolerance` was drawn last, so with a wide tolerance a neighbouring road +// can steal the click. This variant picks the nearest one instead (ties +// still go to the topmost). Otherwise mirrors Leaflet 1.9.4's +// Canvas._onClick / _handleMouseHover. +const NearestHitCanvas = L.Canvas.extend({ + _nearestLayer: function(point, e) { + let best = null, bestDist = Infinity; + for (let order = this._drawFirst; order; order = order.next) { + const layer = order.layer; + if (!layer.options.interactive || !layer._containsPoint(point)) continue; + if (e && (e.type === 'click' || e.type === 'preclick') && this._map._draggableMoved(layer)) continue; + const d = distanceToLayer(layer, point); + if (d <= bestDist) { + best = layer; + bestDist = d; + } + } + return best; + }, + + _onClick: function(e) { + const clickedLayer = this._nearestLayer(this._map.mouseEventToLayerPoint(e), e); + this._fireEvent(clickedLayer ? [clickedLayer] : false, e); + }, + + _handleMouseHover: function(e, point) { + if (this._mouseHoverThrottled) return; + + const candidateHoveredLayer = this._nearestLayer(point); + + if (candidateHoveredLayer !== this._hoveredLayer) { + this._handleMouseOut(e); + + if (candidateHoveredLayer) { + L.DomUtil.addClass(this._container, 'leaflet-interactive'); // change cursor + this._fireEvent([candidateHoveredLayer], e, 'mouseover'); + this._hoveredLayer = candidateHoveredLayer; + } + } + + this._fireEvent(this._hoveredLayer ? [this._hoveredLayer] : false, e); + + this._mouseHoverThrottled = true; + setTimeout(L.Util.bind(function() { + this._mouseHoverThrottled = false; + }, this), 32); + } +}); + +const paneRenderers = {}; +function paneRenderer(pane) { + if (!paneRenderers[pane]) paneRenderers[pane] = new NearestHitCanvas({ pane: pane, tolerance: HIT_TOLERANCE }); + return paneRenderers[pane]; +} + // Mobile controls state let loadedCounty = ''; let loadedDataType = ''; @@ -60,72 +144,94 @@ function initMap() { setupDragSelection(); } -// Setup drag selection functionality +// Setup drag selection functionality. A box is drawn by shift-dragging with +// the mouse, or by dragging with the mouse or one finger while multi-select +// mode is on. In multi-select mode on touch screens, two fingers still pan +// and pinch-zoom: a second finger landing cancels the box. +// A press that never moves past DRAG_THRESHOLD is left alone so the normal +// click/tap handler on the feature (or empty map) runs. +const DRAG_THRESHOLD = 5; + function setupDragSelection() { const mapContainer = map.getContainer(); + let draggingWasEnabled = false; + let activePointerId = null; - map.on('mousedown', function(e) { - // Only start drag selection with shift key or multi-select mode enabled - if (!e.originalEvent.shiftKey && !multiSelectMode) return; - - isDragging = true; - dragStartPoint = e.containerPoint; - - // Create selection box - selectionBox = L.DomUtil.create('div', 'selection-box', mapContainer); - selectionBox.style.left = dragStartPoint.x + 'px'; - selectionBox.style.top = dragStartPoint.y + 'px'; - - // Prevent map panning while dragging - map.dragging.disable(); - - e.originalEvent.preventDefault(); - }); - - map.on('mousemove', function(e) { - if (!isDragging || !selectionBox) return; - - const currentPoint = e.containerPoint; - const minX = Math.min(dragStartPoint.x, currentPoint.x); - const minY = Math.min(dragStartPoint.y, currentPoint.y); - const width = Math.abs(currentPoint.x - dragStartPoint.x); - const height = Math.abs(currentPoint.y - dragStartPoint.y); - - selectionBox.style.left = minX + 'px'; - selectionBox.style.top = minY + 'px'; - selectionBox.style.width = width + 'px'; - selectionBox.style.height = height + 'px'; - }); - - map.on('mouseup', function(e) { - if (!isDragging) return; + function pointFor(e) { + const rect = mapContainer.getBoundingClientRect(); + return L.point(e.clientX - rect.left, e.clientY - rect.top); + } + function endDrag() { isDragging = false; - map.dragging.enable(); - + dragStartPoint = null; + activePointerId = null; if (selectionBox) { - const endPoint = e.containerPoint; - - // Calculate bounds - const minX = Math.min(dragStartPoint.x, endPoint.x); - const minY = Math.min(dragStartPoint.y, endPoint.y); - const maxX = Math.max(dragStartPoint.x, endPoint.x); - const maxY = Math.max(dragStartPoint.y, endPoint.y); - - const bounds = L.latLngBounds( - map.containerPointToLatLng([minX, minY]), - map.containerPointToLatLng([maxX, maxY]) - ); - - // Select features within bounds - selectFeaturesInBounds(bounds); - - // Remove selection box mapContainer.removeChild(selectionBox); selectionBox = null; } + if (draggingWasEnabled) map.dragging.enable(); + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + document.removeEventListener('pointercancel', endDrag); + } - dragStartPoint = null; + function onPointerMove(e) { + if (e.pointerId !== activePointerId) return; + const currentPoint = pointFor(e); + if (!selectionBox) { + if (currentPoint.distanceTo(dragStartPoint) < DRAG_THRESHOLD) return; + selectionBox = L.DomUtil.create('div', 'selection-box', mapContainer); + } + + selectionBox.style.left = Math.min(dragStartPoint.x, currentPoint.x) + 'px'; + selectionBox.style.top = Math.min(dragStartPoint.y, currentPoint.y) + 'px'; + selectionBox.style.width = Math.abs(currentPoint.x - dragStartPoint.x) + 'px'; + selectionBox.style.height = Math.abs(currentPoint.y - dragStartPoint.y) + 'px'; + } + + function onPointerUp(e) { + if (e.pointerId !== activePointerId) return; + if (selectionBox) { + const endPoint = pointFor(e); + const bounds = L.latLngBounds( + map.containerPointToLatLng([Math.min(dragStartPoint.x, endPoint.x), Math.min(dragStartPoint.y, endPoint.y)]), + map.containerPointToLatLng([Math.max(dragStartPoint.x, endPoint.x), Math.max(dragStartPoint.y, endPoint.y)]) + ); + selectFeaturesInBounds(bounds); + + // Swallow the click the browser fires after a box drag so it + // doesn't also toggle whatever feature the drag ended on. + const swallowClick = ev => { ev.stopPropagation(); ev.preventDefault(); }; + mapContainer.addEventListener('click', swallowClick, { capture: true, once: true }); + setTimeout(() => mapContainer.removeEventListener('click', swallowClick, { capture: true }), 0); + } + endDrag(); + } + + // pointerdown fires before Leaflet's own mousedown/touchstart, so + // disabling dragging here stops the map from panning while the box is drawn. + mapContainer.addEventListener('pointerdown', function(e) { + // Second finger down: abandon the box and let Leaflet pan/pinch-zoom. + if (isDragging) { + if (e.pointerType !== 'mouse') endDrag(); + return; + } + if (e.pointerType === 'mouse') { + if (e.button !== 0 || (!e.shiftKey && !multiSelectMode)) return; + } else if (!multiSelectMode) { + return; + } + if (e.target.closest('.leaflet-control, .leaflet-popup')) return; + + isDragging = true; + activePointerId = e.pointerId; + dragStartPoint = pointFor(e); + draggingWasEnabled = map.dragging.enabled(); + map.dragging.disable(); + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); + document.addEventListener('pointercancel', endDrag); }); } @@ -215,6 +321,7 @@ function clearSelection() { selectedFeatures = []; selectedLayers = []; updateSnapPreview(); + updateMultiSelectCount(); if (featurePopup) { map.closePopup(featurePopup); @@ -390,6 +497,7 @@ function createOsmLayer() { style: osmStyle, filter: shouldShowOsmFeature, pane: 'osmPane', + renderer: paneRenderer('osmPane'), pointToLayer: function(feature, latlng) { return L.circleMarker(latlng, osmMarkerStyle(feature)); }, @@ -488,6 +596,7 @@ function createDiffLayer() { style: diffStyle, filter: shouldShowFeature, pane: 'diffPane', + renderer: paneRenderer('diffPane'), pointToLayer: function(feature, latlng) { return L.circleMarker(latlng, diffMarkerStyle(feature)); }, @@ -562,6 +671,7 @@ function createCountyLayer() { style: countyStyle, filter: shouldShowCountyFeature, pane: 'countyPane', + renderer: paneRenderer('countyPane'), pointToLayer: function(feature, latlng) { return L.circleMarker(latlng, countyMarkerStyle(feature)); }, @@ -707,6 +817,7 @@ function selectFeature(feature, layer, e, layerType = 'diff') { function showMultiFeaturePopup(latlng) { if (selectedFeatures.length === 0) return; popupLatlng = latlng; + updateMultiSelectCount(); // Remove old popup without triggering clearSelection if (featurePopup) { @@ -714,7 +825,7 @@ function showMultiFeaturePopup(latlng) { map.closePopup(featurePopup); } - let html = '
'; + let html = '
'; // Show count html += `
Selected: ${selectedFeatures.length} feature${selectedFeatures.length > 1 ? 's' : ''}
`; @@ -889,7 +1000,7 @@ function showMultiFeaturePopup(latlng) { // Create popup at click location featurePopup = L.popup({ - maxWidth: 300, + maxWidth: Math.min(300, window.innerWidth - 60), closeButton: true, autoClose: false, closeOnClick: false @@ -1487,6 +1598,27 @@ function buildNodeRefCount() { return counts; } +// Feature properties that are bookkeeping from our pipeline/UI, not OSM tags, +// and must never be written into the JOSM export. +const NON_OSM_PROPERTIES = new Set([ + // map viewer state + 'removed', 'status', 'accepted', + // OSM identity/version info carried along from the Overpass download + 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions', + // compare-addresses.py annotations + 'conflict', 'county_city', 'county_postal_community', +]); + +// Turn feature properties into an OSM tag map, dropping internal keys and +// empty values. +function osmTagsFrom(props) { + const tags = {}; + Object.entries(props || {}).forEach(([k, v]) => { + if (!NON_OSM_PROPERTIES.has(k) && v !== null && v !== undefined && v !== '') tags[k] = String(v); + }); + return tags; +} + function computeRemovalPlan(removedFeat, acceptedAddedFeats, nodeRefCount) { const props = removedFeat.properties || {}; const osmId = props.osm_id || props['@id']; @@ -1498,11 +1630,7 @@ function computeRemovalPlan(removedFeat, acceptedAddedFeats, nodeRefCount) { return { type: 'deleteNode', osmId, osmVersion, description: `Delete node${osmId ? ' ' + osmId : ''}` }; } - const tags = {}; - Object.entries(props).forEach(([k, v]) => { - if (!['removed', 'status', 'accepted', 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions'].includes(k) && - v !== null && v !== undefined && v !== '') tags[k] = String(v); - }); + const tags = osmTagsFrom(props); if (!osmNodes || !wayCoords || osmNodes.length !== wayCoords.length) { return { type: 'delete', osmId, osmVersion, wayCoords, tags, description: `Delete way${osmId ? ' ' + osmId : ''} (no node data)` }; @@ -1655,11 +1783,7 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) { const deleteNodeIds = new Map(); // id -> version acceptedAdded.forEach(feat => { - const tags = {}; - Object.entries(feat.properties || {}).forEach(([k, v]) => { - if (!['removed', 'status', 'accepted', 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions'].includes(k) && - v !== null && v !== undefined && v !== '') tags[k] = String(v); - }); + const tags = osmTagsFrom(feat.properties); if (feat.geometry.type === 'Point') { newNodes.push({ id: nextNewId--, coord: feat.geometry.coordinates, tags }); @@ -1692,11 +1816,7 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) { insIdx++; } } - const tags = {}; - Object.entries(feat.properties || {}).forEach(([k, v]) => { - if (!['removed', 'status', 'accepted', 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions'].includes(k) && - v !== null && v !== undefined && v !== '') tags[k] = String(v); - }); + const tags = osmTagsFrom(feat.properties); modifiedWays.push({ id: osmId, version: osmVersion, ndRefs, tags }); }); @@ -2092,6 +2212,27 @@ function toggleLayer(layerId, layer) { updateLayerZIndex(); } +function setMultiSelectMode(on) { + multiSelectMode = on; + const button = document.getElementById('multiSelectToggle'); + const fab = document.getElementById('multiSelectFab'); + button.style.background = on ? '#007bff' : '#6c757d'; + button.textContent = on ? 'Multi-Select: ON' : 'Multi-Select: OFF'; + fab.classList.toggle('active', on); + fab.setAttribute('aria-pressed', on ? 'true' : 'false'); + map.getContainer().classList.toggle('multi-select-active', on); + // Clear selection when turning off multi-select mode + if (!on) clearSelection(); + updateMultiSelectCount(); +} + +function updateMultiSelectCount() { + const fab = document.getElementById('multiSelectFab'); + if (!fab) return; + const n = selectedFeatures.length; + fab.querySelector('.fab-count').textContent = multiSelectMode && n > 0 ? n : ''; +} + // Event listeners document.addEventListener('DOMContentLoaded', function() { initMap(); @@ -2164,7 +2305,7 @@ document.addEventListener('DOMContentLoaded', function() { loadFiles(); // Auto-close controls on mobile after clicking load if (window.innerWidth <= 768) { - setTimeout(closeControls, 300); + setTimeout(window.closeMobileControls, 300); } }); @@ -2174,21 +2315,14 @@ document.addEventListener('DOMContentLoaded', function() { // Export to JOSM button document.getElementById('exportOsmButton').addEventListener('click', exportToOsm); - // Multi-select toggle button + // Multi-select toggles: the one in the controls panel, and the floating + // on-map button shown on mobile where the panel is tucked away. document.getElementById('multiSelectToggle').addEventListener('click', function() { - multiSelectMode = !multiSelectMode; - const button = document.getElementById('multiSelectToggle'); - if (multiSelectMode) { - button.style.background = '#007bff'; - button.textContent = 'Multi-Select: ON'; - map.getContainer().classList.add('multi-select-active'); - } else { - button.style.background = '#6c757d'; - button.textContent = 'Multi-Select: OFF'; - map.getContainer().classList.remove('multi-select-active'); - // Clear selection when turning off multi-select mode - clearSelection(); - } + setMultiSelectMode(!multiSelectMode); + }); + document.getElementById('multiSelectFab').addEventListener('click', function(e) { + e.stopPropagation(); + setMultiSelectMode(!multiSelectMode); }); // Drag and drop for layer reordering diff --git a/web/templates/index.html b/web/templates/index.html index a285aa7..d42a0ad 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -346,6 +346,115 @@ color: #444; font-style: italic; } + /* ── Collapsible top menu (mobile) ── */ + .topbar-menu { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + } + + .topbar-spacer { flex: 1; } + + .topbar-user { font-size: 11px; color: #888; } + + .menu-toggle { + display: none; + margin-left: auto; + width: 40px; + height: 36px; + padding: 8px; + border: none; + border-radius: 5px; + background: #ffffff18; + cursor: pointer; + flex-direction: column; + justify-content: space-between; + } + + .menu-toggle span { + display: block; + height: 2px; + border-radius: 1px; + background: #ddd; + } + + @media (max-width: 768px) { + html, body { height: auto; } + + .page { height: auto; min-height: 100%; } + + .topbar { + flex-wrap: wrap; + position: sticky; + top: 0; + z-index: 10; + } + + .menu-toggle { display: flex; } + + .topbar-menu { + display: none; + flex-basis: 100%; + flex-direction: column; + align-items: stretch; + gap: 6px; + max-width: 560px; + margin: 0 auto; + padding: 4px 0 6px; + } + + .topbar-menu.open { display: flex; } + + .topbar-sep, .topbar-spacer { display: none; } + + .topbar-menu .btn-topbar, + .topbar-menu form, + .topbar-menu form .btn-topbar { + display: block; + width: 100%; + padding: 10px 12px; + font-size: 14px; + text-align: left; + } + + .topbar-user { padding: 4px 2px 0; font-size: 12px; } + + .main { + flex-direction: column; + overflow: visible; + } + + .left { + width: 100%; + max-width: 560px; + margin: 0 auto; + border-right: none; + overflow: visible; + } + + .workflows { overflow: visible; padding: 12px; } + + .county-tab { padding: 12px; font-size: 14px; } + + .step { flex-wrap: wrap; padding: 10px 12px; } + + .step-label { font-size: 13px; min-width: 120px; } + + .step-actions { margin-left: auto; flex-wrap: wrap; justify-content: flex-end; } + + .btn-run { padding: 8px 16px; font-size: 13px; } + + .upload-input { width: 100%; max-width: 200px; } + + .files-strip { max-height: 160px; overflow-y: auto; } + + .right { + min-height: 60vh; + border-top: 1px solid #d8dbe0; + } + } @@ -354,10 +463,14 @@

OSM Import Tools

+ +
-
+
Counties Street Exceptions Exclusions @@ -366,13 +479,14 @@ Open Map Viewer
{% if current_user %} - {{ current_user }} + {{ current_user }}
{% else %} Login {% endif %} +
@@ -611,6 +725,13 @@ }); } + // Mobile top menu toggle + document.getElementById('menuToggle').addEventListener('click', function() { + const menu = document.getElementById('topbarMenu'); + const open = menu.classList.toggle('open'); + this.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + // Init if (activeCounty) selectCounty(activeCounty); @@ -622,6 +743,10 @@ showStatus(`Running: ${scriptName} (${activeCounty})`, 'info'); disableButtons(); document.getElementById('cancelButton').classList.add('active'); + // On narrow screens the console is below the steps; bring it into view + if (window.matchMedia('(max-width: 768px)').matches) { + document.querySelector('.right').scrollIntoView({ behavior: 'smooth', block: 'start' }); + } let forceDownload = false; if (scriptName === 'diff-addresses') { diff --git a/web/templates/map.html b/web/templates/map.html index 3e968ec..e905d76 100644 --- a/web/templates/map.html +++ b/web/templates/map.html @@ -67,6 +67,64 @@ z-index: 1000; } + .multiselect-fab { + display: none; + position: absolute; + top: 64px; + right: 10px; + z-index: 998; /* above the map, below the drawer's overlay (999) */ + width: 44px; + height: 44px; + border: none; + border-radius: 4px; + background: white; + box-shadow: 0 2px 10px rgba(0,0,0,0.2); + align-items: center; + justify-content: center; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + + .multiselect-fab svg { + fill: none; + stroke: #333; + stroke-width: 2; + } + + .multiselect-fab.active { + background: #007bff; + } + + .multiselect-fab.active svg { + stroke: white; + } + + .multiselect-fab .fab-count { + position: absolute; + top: -6px; + right: -6px; + min-width: 20px; + height: 20px; + padding: 0 5px; + border-radius: 10px; + background: #ffc107; + color: #222; + font-size: 11px; + font-weight: 700; + line-height: 20px; + text-align: center; + } + + .multiselect-fab .fab-count:empty { + display: none; + } + + @media (max-width: 768px) { + .multiselect-fab { + display: flex; + } + } + /* Crosshair cursor in multi-select mode */ .leaflet-container.multi-select-active, .leaflet-container.multi-select-active.leaflet-grab { @@ -84,6 +142,15 @@ + + +
@@ -142,7 +209,7 @@ Multi-Select: OFF
- Tip: Enable multi-select or hold Shift to select multiple features by clicking or dragging a box + Tip: Enable multi-select or hold Shift to select multiple features by clicking or dragging a box. On touch screens, turn on multi-select (the grid button on the map), then drag a finger to draw a box or tap features to add or remove them; use two fingers to pan and zoom.

Load Data