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

This commit is contained in:
zyphlar
2026-10-15 12:44:48 -07:00
parent 7e5b3dc2ee
commit 9b8752eef5
6 changed files with 457 additions and 102 deletions
+2 -3
View File
@@ -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
+34 -4
View File
@@ -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);
}
+4 -4
View File
@@ -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);
+222 -88
View File
@@ -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 = '<div style="font-size: 12px; max-height: 400px; overflow-y: auto;">';
let html = '<div style="font-size: 12px; max-height: min(400px, 50vh); overflow-y: auto;">';
// Show count
html += `<div style="margin-bottom: 8px;"><strong>Selected:</strong> ${selectedFeatures.length} feature${selectedFeatures.length > 1 ? 's' : ''}</div>`;
@@ -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
+127 -2
View File
@@ -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;
}
}
</style>
</head>
<body>
@@ -354,10 +463,14 @@
<!-- Top bar -->
<div class="topbar">
<h1>OSM Import Tools</h1>
<button class="menu-toggle" id="menuToggle" aria-label="Toggle menu" aria-expanded="false" aria-controls="topbarMenu">
<span></span><span></span><span></span>
</button>
<div class="topbar-menu" id="topbarMenu">
<div class="topbar-sep"></div>
<button class="btn-topbar" onclick="runScript('ls', '')">List Files</button>
<button class="btn-topbar" onclick="clearData()" style="color:#f88;">Clear Data</button>
<div style="flex:1"></div>
<div class="topbar-spacer"></div>
<a href="/counties" class="btn-topbar">Counties</a>
<a href="/exceptions" class="btn-topbar">Street Exceptions</a>
<a href="/exclusions" class="btn-topbar">Exclusions</a>
@@ -366,13 +479,14 @@
<a href="/map" class="btn-topbar purple">Open Map Viewer</a>
<div class="topbar-sep"></div>
{% if current_user %}
<span style="font-size:11px;color:#888;">{{ current_user }}</span>
<span class="topbar-user">{{ current_user }}</span>
<form method="POST" action="/logout" style="display:inline;margin:0">
<button type="submit" class="btn-topbar">Logout</button>
</form>
{% else %}
<a href="/login" class="btn-topbar" style="color:#f0a830;">Login</a>
{% endif %}
</div><!-- /topbar-menu -->
</div>
<div class="main">
@@ -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') {
+68 -1
View File
@@ -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 @@
<span></span>
</button>
<!-- Floating multi-select toggle (mobile only; desktop uses the panel button) -->
<button class="multiselect-fab" id="multiSelectFab" aria-pressed="false" aria-label="Toggle multi-select">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/>
<rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>
</svg>
<span class="fab-count"></span>
</button>
<!-- Overlay for mobile -->
<div class="overlay" id="overlay"></div>
@@ -142,7 +209,7 @@
Multi-Select: OFF
</button>
<div style="margin-top: 5px; padding: 5px; background: #e7f3ff; border-radius: 4px; font-size: 10px; color: #004085;">
<strong>Tip:</strong> Enable multi-select or hold Shift to select multiple features by clicking or dragging a box
<strong>Tip:</strong> 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.
</div>
<h3 style="margin-top: 10px;">Load Data</h3>