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:
+2
-3
@@ -17,9 +17,8 @@ let layerOrder = ['diff', 'osm', 'county']; // Default layer order (top to botto
|
|||||||
function initMap() {
|
function initMap() {
|
||||||
map = L.map('map').setView([28.7, -81.7], 12);
|
map = L.map('map').setView([28.7, -81.7], 12);
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France',
|
attribution: '© OpenStreetMap contributors',
|
||||||
subdomains: 'abc',
|
|
||||||
|
|
||||||
maxNativeZoom: 19,
|
maxNativeZoom: 19,
|
||||||
maxZoom: 20
|
maxZoom: 20
|
||||||
|
|||||||
+34
-4
@@ -30,6 +30,7 @@ body {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||||
min-width: 175px;
|
min-width: 175px;
|
||||||
|
max-width: 280px;
|
||||||
max-height: calc(100vh - 20px);
|
max-height: calc(100vh - 20px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -154,14 +155,17 @@ body {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: -100%;
|
right: -100%;
|
||||||
width: 80%;
|
width: 85%;
|
||||||
max-width: 320px;
|
min-width: 0;
|
||||||
|
max-width: 300px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: none;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
transition: right 0.3s ease;
|
transition: right 0.3s ease;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
box-shadow: -2px 0 10px rgba(0,0,0,0.2);
|
box-shadow: -2px 0 10px rgba(0,0,0,0.2);
|
||||||
padding-top: 50px;
|
padding-top: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls.open {
|
.controls.open {
|
||||||
@@ -173,7 +177,28 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.controls label {
|
.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;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.btn-back:hover { background: #2d2d4e; color: #fff; }
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
function initBaseMap(elementId, view, zoom) {
|
function initBaseMap(elementId, view, zoom) {
|
||||||
const map = L.map(elementId).setView(view || [28.7, -81.7], 12);
|
const map = L.map(elementId).setView(view || [28.7, -81.7], 12);
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France',
|
attribution: '© OpenStreetMap contributors',
|
||||||
subdomains: 'abc',
|
|
||||||
maxNativeZoom: 19,
|
maxNativeZoom: 19,
|
||||||
maxZoom: 20
|
maxZoom: 20,
|
||||||
|
className: 'base-tiles'
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
if (zoom) map.setZoom(zoom);
|
if (zoom) map.setZoom(zoom);
|
||||||
|
|||||||
+222
-88
@@ -22,6 +22,90 @@ let dragStartPoint = null;
|
|||||||
let selectionBox = null;
|
let selectionBox = null;
|
||||||
let multiSelectMode = false;
|
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
|
// Mobile controls state
|
||||||
let loadedCounty = '';
|
let loadedCounty = '';
|
||||||
let loadedDataType = '';
|
let loadedDataType = '';
|
||||||
@@ -60,72 +144,94 @@ function initMap() {
|
|||||||
setupDragSelection();
|
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() {
|
function setupDragSelection() {
|
||||||
const mapContainer = map.getContainer();
|
const mapContainer = map.getContainer();
|
||||||
|
let draggingWasEnabled = false;
|
||||||
|
let activePointerId = null;
|
||||||
|
|
||||||
map.on('mousedown', function(e) {
|
function pointFor(e) {
|
||||||
// Only start drag selection with shift key or multi-select mode enabled
|
const rect = mapContainer.getBoundingClientRect();
|
||||||
if (!e.originalEvent.shiftKey && !multiSelectMode) return;
|
return L.point(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
|
}
|
||||||
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 endDrag() {
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
map.dragging.enable();
|
dragStartPoint = null;
|
||||||
|
activePointerId = null;
|
||||||
if (selectionBox) {
|
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);
|
mapContainer.removeChild(selectionBox);
|
||||||
selectionBox = null;
|
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 = [];
|
selectedFeatures = [];
|
||||||
selectedLayers = [];
|
selectedLayers = [];
|
||||||
updateSnapPreview();
|
updateSnapPreview();
|
||||||
|
updateMultiSelectCount();
|
||||||
|
|
||||||
if (featurePopup) {
|
if (featurePopup) {
|
||||||
map.closePopup(featurePopup);
|
map.closePopup(featurePopup);
|
||||||
@@ -390,6 +497,7 @@ function createOsmLayer() {
|
|||||||
style: osmStyle,
|
style: osmStyle,
|
||||||
filter: shouldShowOsmFeature,
|
filter: shouldShowOsmFeature,
|
||||||
pane: 'osmPane',
|
pane: 'osmPane',
|
||||||
|
renderer: paneRenderer('osmPane'),
|
||||||
pointToLayer: function(feature, latlng) {
|
pointToLayer: function(feature, latlng) {
|
||||||
return L.circleMarker(latlng, osmMarkerStyle(feature));
|
return L.circleMarker(latlng, osmMarkerStyle(feature));
|
||||||
},
|
},
|
||||||
@@ -488,6 +596,7 @@ function createDiffLayer() {
|
|||||||
style: diffStyle,
|
style: diffStyle,
|
||||||
filter: shouldShowFeature,
|
filter: shouldShowFeature,
|
||||||
pane: 'diffPane',
|
pane: 'diffPane',
|
||||||
|
renderer: paneRenderer('diffPane'),
|
||||||
pointToLayer: function(feature, latlng) {
|
pointToLayer: function(feature, latlng) {
|
||||||
return L.circleMarker(latlng, diffMarkerStyle(feature));
|
return L.circleMarker(latlng, diffMarkerStyle(feature));
|
||||||
},
|
},
|
||||||
@@ -562,6 +671,7 @@ function createCountyLayer() {
|
|||||||
style: countyStyle,
|
style: countyStyle,
|
||||||
filter: shouldShowCountyFeature,
|
filter: shouldShowCountyFeature,
|
||||||
pane: 'countyPane',
|
pane: 'countyPane',
|
||||||
|
renderer: paneRenderer('countyPane'),
|
||||||
pointToLayer: function(feature, latlng) {
|
pointToLayer: function(feature, latlng) {
|
||||||
return L.circleMarker(latlng, countyMarkerStyle(feature));
|
return L.circleMarker(latlng, countyMarkerStyle(feature));
|
||||||
},
|
},
|
||||||
@@ -707,6 +817,7 @@ function selectFeature(feature, layer, e, layerType = 'diff') {
|
|||||||
function showMultiFeaturePopup(latlng) {
|
function showMultiFeaturePopup(latlng) {
|
||||||
if (selectedFeatures.length === 0) return;
|
if (selectedFeatures.length === 0) return;
|
||||||
popupLatlng = latlng;
|
popupLatlng = latlng;
|
||||||
|
updateMultiSelectCount();
|
||||||
|
|
||||||
// Remove old popup without triggering clearSelection
|
// Remove old popup without triggering clearSelection
|
||||||
if (featurePopup) {
|
if (featurePopup) {
|
||||||
@@ -714,7 +825,7 @@ function showMultiFeaturePopup(latlng) {
|
|||||||
map.closePopup(featurePopup);
|
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
|
// Show count
|
||||||
html += `<div style="margin-bottom: 8px;"><strong>Selected:</strong> ${selectedFeatures.length} feature${selectedFeatures.length > 1 ? 's' : ''}</div>`;
|
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
|
// Create popup at click location
|
||||||
featurePopup = L.popup({
|
featurePopup = L.popup({
|
||||||
maxWidth: 300,
|
maxWidth: Math.min(300, window.innerWidth - 60),
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
autoClose: false,
|
autoClose: false,
|
||||||
closeOnClick: false
|
closeOnClick: false
|
||||||
@@ -1487,6 +1598,27 @@ function buildNodeRefCount() {
|
|||||||
return counts;
|
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) {
|
function computeRemovalPlan(removedFeat, acceptedAddedFeats, nodeRefCount) {
|
||||||
const props = removedFeat.properties || {};
|
const props = removedFeat.properties || {};
|
||||||
const osmId = props.osm_id || props['@id'];
|
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 : ''}` };
|
return { type: 'deleteNode', osmId, osmVersion, description: `Delete node${osmId ? ' ' + osmId : ''}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = {};
|
const tags = osmTagsFrom(props);
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!osmNodes || !wayCoords || osmNodes.length !== wayCoords.length) {
|
if (!osmNodes || !wayCoords || osmNodes.length !== wayCoords.length) {
|
||||||
return { type: 'delete', osmId, osmVersion, wayCoords, tags, description: `Delete way${osmId ? ' ' + osmId : ''} (no node data)` };
|
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
|
const deleteNodeIds = new Map(); // id -> version
|
||||||
|
|
||||||
acceptedAdded.forEach(feat => {
|
acceptedAdded.forEach(feat => {
|
||||||
const tags = {};
|
const tags = osmTagsFrom(feat.properties);
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (feat.geometry.type === 'Point') {
|
if (feat.geometry.type === 'Point') {
|
||||||
newNodes.push({ id: nextNewId--, coord: feat.geometry.coordinates, tags });
|
newNodes.push({ id: nextNewId--, coord: feat.geometry.coordinates, tags });
|
||||||
@@ -1692,11 +1816,7 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) {
|
|||||||
insIdx++;
|
insIdx++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const tags = {};
|
const tags = osmTagsFrom(feat.properties);
|
||||||
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);
|
|
||||||
});
|
|
||||||
modifiedWays.push({ id: osmId, version: osmVersion, ndRefs, tags });
|
modifiedWays.push({ id: osmId, version: osmVersion, ndRefs, tags });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2092,6 +2212,27 @@ function toggleLayer(layerId, layer) {
|
|||||||
updateLayerZIndex();
|
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
|
// Event listeners
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initMap();
|
initMap();
|
||||||
@@ -2164,7 +2305,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
loadFiles();
|
loadFiles();
|
||||||
// Auto-close controls on mobile after clicking load
|
// Auto-close controls on mobile after clicking load
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
setTimeout(closeControls, 300);
|
setTimeout(window.closeMobileControls, 300);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2174,21 +2315,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Export to JOSM button
|
// Export to JOSM button
|
||||||
document.getElementById('exportOsmButton').addEventListener('click', exportToOsm);
|
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() {
|
document.getElementById('multiSelectToggle').addEventListener('click', function() {
|
||||||
multiSelectMode = !multiSelectMode;
|
setMultiSelectMode(!multiSelectMode);
|
||||||
const button = document.getElementById('multiSelectToggle');
|
});
|
||||||
if (multiSelectMode) {
|
document.getElementById('multiSelectFab').addEventListener('click', function(e) {
|
||||||
button.style.background = '#007bff';
|
e.stopPropagation();
|
||||||
button.textContent = 'Multi-Select: ON';
|
setMultiSelectMode(!multiSelectMode);
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drag and drop for layer reordering
|
// Drag and drop for layer reordering
|
||||||
|
|||||||
+127
-2
@@ -346,6 +346,115 @@
|
|||||||
color: #444;
|
color: #444;
|
||||||
font-style: italic;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -354,10 +463,14 @@
|
|||||||
<!-- Top bar -->
|
<!-- Top bar -->
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<h1>OSM Import Tools</h1>
|
<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>
|
<div class="topbar-sep"></div>
|
||||||
<button class="btn-topbar" onclick="runScript('ls', '')">List Files</button>
|
<button class="btn-topbar" onclick="runScript('ls', '')">List Files</button>
|
||||||
<button class="btn-topbar" onclick="clearData()" style="color:#f88;">Clear Data</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="/counties" class="btn-topbar">Counties</a>
|
||||||
<a href="/exceptions" class="btn-topbar">Street Exceptions</a>
|
<a href="/exceptions" class="btn-topbar">Street Exceptions</a>
|
||||||
<a href="/exclusions" class="btn-topbar">Exclusions</a>
|
<a href="/exclusions" class="btn-topbar">Exclusions</a>
|
||||||
@@ -366,13 +479,14 @@
|
|||||||
<a href="/map" class="btn-topbar purple">Open Map Viewer</a>
|
<a href="/map" class="btn-topbar purple">Open Map Viewer</a>
|
||||||
<div class="topbar-sep"></div>
|
<div class="topbar-sep"></div>
|
||||||
{% if current_user %}
|
{% 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">
|
<form method="POST" action="/logout" style="display:inline;margin:0">
|
||||||
<button type="submit" class="btn-topbar">Logout</button>
|
<button type="submit" class="btn-topbar">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/login" class="btn-topbar" style="color:#f0a830;">Login</a>
|
<a href="/login" class="btn-topbar" style="color:#f0a830;">Login</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div><!-- /topbar-menu -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="main">
|
<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
|
// Init
|
||||||
if (activeCounty) selectCounty(activeCounty);
|
if (activeCounty) selectCounty(activeCounty);
|
||||||
|
|
||||||
@@ -622,6 +743,10 @@
|
|||||||
showStatus(`Running: ${scriptName} (${activeCounty})`, 'info');
|
showStatus(`Running: ${scriptName} (${activeCounty})`, 'info');
|
||||||
disableButtons();
|
disableButtons();
|
||||||
document.getElementById('cancelButton').classList.add('active');
|
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;
|
let forceDownload = false;
|
||||||
if (scriptName === 'diff-addresses') {
|
if (scriptName === 'diff-addresses') {
|
||||||
|
|||||||
+68
-1
@@ -67,6 +67,64 @@
|
|||||||
z-index: 1000;
|
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 */
|
/* Crosshair cursor in multi-select mode */
|
||||||
.leaflet-container.multi-select-active,
|
.leaflet-container.multi-select-active,
|
||||||
.leaflet-container.multi-select-active.leaflet-grab {
|
.leaflet-container.multi-select-active.leaflet-grab {
|
||||||
@@ -84,6 +142,15 @@
|
|||||||
<span></span>
|
<span></span>
|
||||||
</button>
|
</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 -->
|
<!-- Overlay for mobile -->
|
||||||
<div class="overlay" id="overlay"></div>
|
<div class="overlay" id="overlay"></div>
|
||||||
|
|
||||||
@@ -142,7 +209,7 @@
|
|||||||
Multi-Select: OFF
|
Multi-Select: OFF
|
||||||
</button>
|
</button>
|
||||||
<div style="margin-top: 5px; padding: 5px; background: #e7f3ff; border-radius: 4px; font-size: 10px; color: #004085;">
|
<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>
|
</div>
|
||||||
|
|
||||||
<h3 style="margin-top: 10px;">Load Data</h3>
|
<h3 style="margin-top: 10px;">Load Data</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user