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 = '