2651 lines
107 KiB
JavaScript
2651 lines
107 KiB
JavaScript
// Global state
|
|
let map;
|
|
let osmLayer;
|
|
let diffLayer;
|
|
let countyLayer;
|
|
let osmData = null;
|
|
let diffData = null;
|
|
let countyData = null;
|
|
let selectedFeatures = [];
|
|
let selectedLayers = [];
|
|
let acceptedFeatures = new Set();
|
|
let rejectedFeatures = new Set();
|
|
let featurePopup = null;
|
|
let layerOrder = ['diff', 'osm', 'county']; // Default layer order (top to bottom)
|
|
|
|
// Exclusions loaded from server
|
|
let exclusions = [];
|
|
|
|
// Drag selection state
|
|
let isDragging = false;
|
|
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.
|
|
//
|
|
// Each road layer also has its own canvas in its own pane (so the layer-order
|
|
// controls can restack them), and the topmost canvas covers the whole map, so
|
|
// it receives every mouse event. Whichever canvas gets the event therefore
|
|
// hit-tests every pane, topmost first, falling through to the next pane down
|
|
// only when nothing in the one above is within tolerance.
|
|
const NearestHitCanvas = L.Canvas.extend({
|
|
_hitTest: function(point, e) {
|
|
const renderers = Object.values(paneRenderers)
|
|
.filter(r => r._map)
|
|
.sort((a, b) => (parseInt(b.getPane().style.zIndex, 10) || 0) - (parseInt(a.getPane().style.zIndex, 10) || 0));
|
|
for (const r of renderers) {
|
|
const layer = r._nearestLayer(point, e);
|
|
if (layer) return layer;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
_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._hitTest(this._map.mouseEventToLayerPoint(e), e);
|
|
this._fireEvent(clickedLayer ? [clickedLayer] : false, e);
|
|
},
|
|
|
|
_handleMouseHover: function(e, point) {
|
|
if (this._mouseHoverThrottled) return;
|
|
|
|
const candidateHoveredLayer = this._hitTest(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 = '';
|
|
|
|
// Tracks county features that have been imported (so countyStyle can highlight them)
|
|
let importedCountyFeatures = new Set();
|
|
|
|
// OSM node index for JOSM export snapping
|
|
let osmVertices = [];
|
|
let osmNodeById = new Map();
|
|
let osmCoordToNodeId = new Map();
|
|
let osmNodeVersionById = new Map();
|
|
let SNAP_TOL = 10 / 111320; // default 10m in degrees; updated from UI
|
|
|
|
// Snap preview layer and popup state
|
|
let snapPreviewLayer = null;
|
|
let popupLatlng = null;
|
|
|
|
// Snap end dots for the road under the mouse (any added road, selected or
|
|
// not), so an end can be toggled without opening a popup that may cover it.
|
|
let hoverSnapLayer = null;
|
|
let hoverSnapFeature = null;
|
|
let hoverSnapHideTimer = null;
|
|
|
|
// Initialize map
|
|
function initMap() {
|
|
map = initBaseMap('map', [28.7, -81.7], 12);
|
|
|
|
snapPreviewLayer = L.layerGroup().addTo(map);
|
|
hoverSnapLayer = L.layerGroup().addTo(map);
|
|
|
|
// Create custom panes for layer ordering
|
|
map.createPane('osmPane');
|
|
map.createPane('diffPane');
|
|
map.createPane('countyPane');
|
|
map.createPane('snapPane');
|
|
map.getPane('snapPane').style.zIndex = 660; // above road panes (~652), below popups (700)
|
|
|
|
// Set initial z-indices for panes based on layerOrder
|
|
updateLayerZIndex();
|
|
|
|
// Setup drag selection
|
|
setupDragSelection();
|
|
}
|
|
|
|
// 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;
|
|
|
|
function pointFor(e) {
|
|
const rect = mapContainer.getBoundingClientRect();
|
|
return L.point(e.clientX - rect.left, e.clientY - rect.top);
|
|
}
|
|
|
|
function endDrag() {
|
|
isDragging = false;
|
|
dragStartPoint = null;
|
|
activePointerId = null;
|
|
if (selectionBox) {
|
|
mapContainer.removeChild(selectionBox);
|
|
selectionBox = null;
|
|
}
|
|
if (draggingWasEnabled) map.dragging.enable();
|
|
document.removeEventListener('pointermove', onPointerMove);
|
|
document.removeEventListener('pointerup', onPointerUp);
|
|
document.removeEventListener('pointercancel', endDrag);
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
// Select features within bounds
|
|
function selectFeaturesInBounds(bounds) {
|
|
clearSelection();
|
|
|
|
// Only select from diff layer (OSM/county items can't be accepted/rejected)
|
|
const layers = [
|
|
{ layer: diffLayer, data: diffData, type: 'diff' }
|
|
];
|
|
|
|
layers.forEach(({ layer, data, type }) => {
|
|
if (!layer || !data) return;
|
|
|
|
layer.eachLayer(function(leafletLayer) {
|
|
const feature = leafletLayer.feature;
|
|
if (!feature) return;
|
|
|
|
let isInBounds = false;
|
|
|
|
// Check if feature is within bounds
|
|
if (feature.geometry.type === 'Point') {
|
|
const coords = feature.geometry.coordinates;
|
|
const latlng = L.latLng(coords[1], coords[0]);
|
|
isInBounds = bounds.contains(latlng);
|
|
} else if (feature.geometry.type === 'LineString') {
|
|
// Check if any point of the linestring is within bounds
|
|
const coords = feature.geometry.coordinates;
|
|
isInBounds = coords.some(coord => {
|
|
const latlng = L.latLng(coord[1], coord[0]);
|
|
return bounds.contains(latlng);
|
|
});
|
|
}
|
|
|
|
if (isInBounds) {
|
|
selectedFeatures.push(feature);
|
|
selectedLayers.push({ layer: leafletLayer, type: type });
|
|
|
|
// Highlight selected feature
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
if (isPoint) {
|
|
leafletLayer.setStyle({
|
|
radius: 9,
|
|
fillColor: '#ffc107',
|
|
color: '#ff9800',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1
|
|
});
|
|
} else {
|
|
leafletLayer.setStyle({
|
|
weight: 6,
|
|
opacity: 1,
|
|
color: '#ffc107'
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
if (selectedFeatures.length > 0) {
|
|
// Calculate center point for popup
|
|
const center = bounds.getCenter();
|
|
showMultiFeaturePopup(center);
|
|
}
|
|
}
|
|
|
|
// Clear current selection
|
|
function clearSelection() {
|
|
// Restore original styles for previously selected features
|
|
selectedLayers.forEach(({ layer, type }) => {
|
|
const feature = layer.feature;
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
|
|
if (isPoint) {
|
|
const markerStyleFunc = type === 'diff' ? diffMarkerStyle :
|
|
type === 'osm' ? osmMarkerStyle : countyMarkerStyle;
|
|
layer.setStyle(markerStyleFunc(feature));
|
|
} else {
|
|
const styleFunc = type === 'diff' ? diffStyle :
|
|
type === 'osm' ? osmStyle : countyStyle;
|
|
layer.setStyle(styleFunc(feature));
|
|
}
|
|
});
|
|
|
|
selectedFeatures = [];
|
|
selectedLayers = [];
|
|
updateSnapPreview();
|
|
updateMultiSelectCount();
|
|
|
|
if (featurePopup) {
|
|
map.closePopup(featurePopup);
|
|
featurePopup = null;
|
|
}
|
|
}
|
|
|
|
// Calculate bounds for all loaded layers
|
|
function calculateBounds() {
|
|
const bounds = L.latLngBounds([]);
|
|
let hasData = false;
|
|
|
|
if (osmData && osmData.features.length > 0) {
|
|
L.geoJSON(osmData).eachLayer(layer => {
|
|
if (layer.getBounds) {
|
|
bounds.extend(layer.getBounds());
|
|
} else if (layer.getLatLng) {
|
|
bounds.extend(layer.getLatLng());
|
|
}
|
|
});
|
|
hasData = true;
|
|
}
|
|
|
|
if (diffData && diffData.features.length > 0) {
|
|
L.geoJSON(diffData).eachLayer(layer => {
|
|
if (layer.getBounds) {
|
|
bounds.extend(layer.getBounds());
|
|
} else if (layer.getLatLng) {
|
|
bounds.extend(layer.getLatLng());
|
|
}
|
|
});
|
|
hasData = true;
|
|
}
|
|
|
|
if (countyData && countyData.features.length > 0) {
|
|
L.geoJSON(countyData).eachLayer(layer => {
|
|
if (layer.getBounds) {
|
|
bounds.extend(layer.getBounds());
|
|
} else if (layer.getLatLng) {
|
|
bounds.extend(layer.getLatLng());
|
|
}
|
|
});
|
|
hasData = true;
|
|
}
|
|
|
|
if (hasData && bounds.isValid()) {
|
|
map.fitBounds(bounds, { padding: [50, 50] });
|
|
}
|
|
}
|
|
|
|
// Style functions for lines
|
|
function osmStyle(feature) {
|
|
return {
|
|
color: '#4a4a4a',
|
|
weight: 3,
|
|
opacity: 0.7
|
|
};
|
|
}
|
|
|
|
// Style functions for point markers (addresses)
|
|
function osmMarkerStyle(feature) {
|
|
return {
|
|
radius: 6,
|
|
fillColor: '#4a4a4a',
|
|
color: '#333',
|
|
weight: 1,
|
|
opacity: 0.8,
|
|
fillOpacity: 0.6
|
|
};
|
|
}
|
|
|
|
function diffStyle(feature) {
|
|
// Check if feature is accepted or rejected
|
|
if (acceptedFeatures.has(feature)) {
|
|
return {
|
|
color: '#007bff',
|
|
weight: 3,
|
|
opacity: 0.8
|
|
};
|
|
}
|
|
|
|
if (rejectedFeatures.has(feature)) {
|
|
return {
|
|
color: '#ff8c00',
|
|
weight: 3,
|
|
opacity: 0.8
|
|
};
|
|
}
|
|
|
|
const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True');
|
|
const isConflicted = !isRemoved && feature.properties &&
|
|
(feature.properties.conflict === true || feature.properties.conflict === 'True');
|
|
// Medium green: crosses an existing OSM road of the same name (see markPossibleRenames)
|
|
const addedColor = feature._possibleRename ? '#1f9e3a' : '#00ff00';
|
|
return {
|
|
color: isRemoved ? '#ff0000' : (isConflicted ? '#c9a800' : addedColor),
|
|
weight: 3,
|
|
opacity: 0.8
|
|
};
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function hasPossibleRename(feature) {
|
|
const v = feature.properties && feature.properties.possible_rename;
|
|
return typeof v === 'string' && v !== '' && v !== 'None' && v !== 'nan';
|
|
}
|
|
|
|
function diffMarkerStyle(feature) {
|
|
// Check if feature is accepted or rejected
|
|
if (acceptedFeatures.has(feature)) {
|
|
// Darker blue: accepted as a street rename of the existing OSM address
|
|
const rename = !!feature._acceptRename;
|
|
return {
|
|
radius: 6,
|
|
fillColor: rename ? '#0b3d91' : '#007bff',
|
|
color: rename ? '#061f4a' : '#0056b3',
|
|
weight: 1,
|
|
opacity: 0.8,
|
|
fillOpacity: 0.7
|
|
};
|
|
}
|
|
|
|
if (rejectedFeatures.has(feature)) {
|
|
return {
|
|
radius: 6,
|
|
fillColor: '#ff8c00',
|
|
color: '#d47200',
|
|
weight: 1,
|
|
opacity: 0.8,
|
|
fillOpacity: 0.7
|
|
};
|
|
}
|
|
|
|
const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True');
|
|
const isConflicted = !isRemoved && feature.properties &&
|
|
(feature.properties.conflict === true || feature.properties.conflict === 'True');
|
|
// Darker: same house number nearby on a differently named street (see
|
|
// compare-addresses.py _flag_possible_renames)
|
|
const rename = hasPossibleRename(feature);
|
|
const fill = isRemoved ? (rename ? '#a00000' : '#ff0000')
|
|
: (isConflicted ? '#ffeb3b' : (rename ? '#1f9e3a' : '#00ff00'));
|
|
const stroke = isRemoved ? (rename ? '#600000' : '#cc0000')
|
|
: (isConflicted ? '#c9a800' : (rename ? '#0f5c21' : '#00cc00'));
|
|
return {
|
|
radius: 6,
|
|
fillColor: fill,
|
|
color: stroke,
|
|
weight: 1,
|
|
opacity: 0.8,
|
|
fillOpacity: 0.7
|
|
};
|
|
}
|
|
|
|
function countyStyle(feature) {
|
|
if (importedCountyFeatures.has(feature)) {
|
|
return { color: '#007bff', weight: 3, opacity: 0.8 };
|
|
}
|
|
return { color: '#ff00ff', weight: 3, opacity: 0.8 };
|
|
}
|
|
|
|
function countyMarkerStyle(feature) {
|
|
if (importedCountyFeatures.has(feature)) {
|
|
return { radius: 6, fillColor: '#007bff', color: '#0056b3', weight: 1, opacity: 0.8, fillOpacity: 0.7 };
|
|
}
|
|
return { radius: 6, fillColor: '#ff00ff', color: '#cc00cc', weight: 1, opacity: 0.8, fillOpacity: 0.6 };
|
|
}
|
|
|
|
// Filter function for OSM features
|
|
function shouldShowOsmFeature(feature) {
|
|
const props = feature.properties || {};
|
|
const isService = props.highway === 'service' || props.highway === 'track';
|
|
const isUnclassified = props.highway === 'unclassified';
|
|
const hideService = document.getElementById('hideService').checked;
|
|
const hideUnclassified = document.getElementById('hideUnclassified').checked;
|
|
|
|
if (isService && hideService) return false;
|
|
if (isUnclassified && hideUnclassified) return false;
|
|
return true;
|
|
}
|
|
|
|
// Create layer for OSM data
|
|
function createOsmLayer() {
|
|
if (osmLayer) {
|
|
map.removeLayer(osmLayer);
|
|
}
|
|
|
|
if (!osmData) return;
|
|
|
|
osmLayer = L.geoJSON(osmData, {
|
|
style: osmStyle,
|
|
filter: shouldShowOsmFeature,
|
|
pane: 'osmPane',
|
|
renderer: paneRenderer('osmPane'),
|
|
pointToLayer: function(feature, latlng) {
|
|
return L.circleMarker(latlng, osmMarkerStyle(feature));
|
|
},
|
|
onEachFeature: function(feature, layer) {
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
|
|
layer.on('click', function(e) {
|
|
L.DomEvent.stopPropagation(e);
|
|
selectFeature(feature, layer, e, 'osm');
|
|
});
|
|
|
|
layer.on('mouseover', function(e) {
|
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
|
if (!isSelected) {
|
|
if (isPoint) {
|
|
layer.setStyle({
|
|
radius: 8,
|
|
fillOpacity: 1
|
|
});
|
|
} else {
|
|
layer.setStyle({
|
|
weight: 5,
|
|
opacity: 1
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
layer.on('mouseout', function(e) {
|
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
|
if (!isSelected) {
|
|
if (isPoint) {
|
|
layer.setStyle(osmMarkerStyle(feature));
|
|
} else {
|
|
layer.setStyle(osmStyle(feature));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Only add to map if checkbox is checked
|
|
if (document.getElementById('osmToggle').checked) {
|
|
osmLayer.addTo(map);
|
|
}
|
|
|
|
updateLayerZIndex();
|
|
}
|
|
|
|
// Check if a feature matches any server-side exclusion
|
|
function isExcluded(feature) {
|
|
if (!exclusions.length) return false;
|
|
const props = feature.properties || {};
|
|
return exclusions.some(excl => {
|
|
const val = props[excl.field];
|
|
return val !== undefined && val !== null && String(val) === String(excl.value);
|
|
});
|
|
}
|
|
|
|
// Filter function for diff features
|
|
function shouldShowFeature(feature) {
|
|
if (isExcluded(feature)) return false;
|
|
const props = feature.properties || {};
|
|
const isRemoved = props.removed === true || props.removed === 'True';
|
|
const isConflicted = !isRemoved && (props.conflict === true || props.conflict === 'True');
|
|
const isService = props.highway === 'service' || props.highway === 'track';
|
|
const isUnclassified = props.highway === 'unclassified';
|
|
|
|
const showAdded = document.getElementById('showAdded').checked;
|
|
const showConflicted = document.getElementById('showConflicted').checked;
|
|
const showRemoved = document.getElementById('showRemoved').checked;
|
|
const hideService = document.getElementById('hideService').checked;
|
|
const hideUnclassified = document.getElementById('hideUnclassified').checked;
|
|
|
|
// Check removed/added/conflicted filter
|
|
if (isRemoved && !showRemoved) return false;
|
|
if (isConflicted && !showConflicted) return false;
|
|
if (!isRemoved && !isConflicted && !showAdded) return false;
|
|
|
|
// Check service/track and unclassified filters
|
|
if (isService && hideService) return false;
|
|
if (isUnclassified && hideUnclassified) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
// Create layer for diff data with click handlers
|
|
function createDiffLayer() {
|
|
if (diffLayer) {
|
|
map.removeLayer(diffLayer);
|
|
}
|
|
|
|
if (!diffData) return;
|
|
|
|
diffLayer = L.geoJSON(diffData, {
|
|
style: diffStyle,
|
|
filter: shouldShowFeature,
|
|
pane: 'diffPane',
|
|
renderer: paneRenderer('diffPane'),
|
|
pointToLayer: function(feature, latlng) {
|
|
return L.circleMarker(latlng, diffMarkerStyle(feature));
|
|
},
|
|
onEachFeature: function(feature, layer) {
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
|
|
layer.on('click', function(e) {
|
|
L.DomEvent.stopPropagation(e);
|
|
selectFeature(feature, layer, e, 'diff');
|
|
});
|
|
|
|
layer.on('mouseover', function(e) {
|
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
|
if (!isPoint) showHoverSnapDots(feature);
|
|
if (!isSelected) {
|
|
if (isPoint) {
|
|
layer.setStyle({
|
|
radius: 8,
|
|
fillOpacity: 1
|
|
});
|
|
} else {
|
|
layer.setStyle({
|
|
weight: 5,
|
|
opacity: 1
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
layer.on('mouseout', function(e) {
|
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
|
if (!isPoint) scheduleHideHoverSnap();
|
|
if (!isSelected) {
|
|
if (isPoint) {
|
|
layer.setStyle(diffMarkerStyle(feature));
|
|
} else {
|
|
layer.setStyle(diffStyle(feature));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Only add to map if checkbox is checked
|
|
if (document.getElementById('diffToggle').checked) {
|
|
diffLayer.addTo(map);
|
|
}
|
|
|
|
updateLayerZIndex();
|
|
}
|
|
|
|
// Filter function for county features
|
|
function shouldShowCountyFeature(feature) {
|
|
const props = feature.properties || {};
|
|
const isService = props.highway === 'service' || props.highway === 'track';
|
|
const isUnclassified = props.highway === 'unclassified';
|
|
const hideService = document.getElementById('hideService').checked;
|
|
const hideUnclassified = document.getElementById('hideUnclassified').checked;
|
|
|
|
if (isService && hideService) return false;
|
|
if (isUnclassified && hideUnclassified) return false;
|
|
return true;
|
|
}
|
|
|
|
// Create layer for county data
|
|
function createCountyLayer() {
|
|
if (countyLayer) {
|
|
map.removeLayer(countyLayer);
|
|
}
|
|
|
|
if (!countyData) return;
|
|
|
|
countyLayer = L.geoJSON(countyData, {
|
|
style: countyStyle,
|
|
filter: shouldShowCountyFeature,
|
|
pane: 'countyPane',
|
|
renderer: paneRenderer('countyPane'),
|
|
pointToLayer: function(feature, latlng) {
|
|
return L.circleMarker(latlng, countyMarkerStyle(feature));
|
|
},
|
|
onEachFeature: function(feature, layer) {
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
|
|
layer.on('click', function(e) {
|
|
L.DomEvent.stopPropagation(e);
|
|
selectFeature(feature, layer, e, 'county');
|
|
});
|
|
|
|
layer.on('mouseover', function(e) {
|
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
|
if (!isSelected) {
|
|
if (isPoint) {
|
|
layer.setStyle({
|
|
radius: 8,
|
|
fillOpacity: 1
|
|
});
|
|
} else {
|
|
layer.setStyle({
|
|
weight: 5,
|
|
opacity: 1
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
layer.on('mouseout', function(e) {
|
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
|
if (!isSelected) {
|
|
if (isPoint) {
|
|
layer.setStyle(countyMarkerStyle(feature));
|
|
} else {
|
|
layer.setStyle(countyStyle(feature));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// County layer is hidden by default
|
|
if (document.getElementById('countyToggle').checked) {
|
|
countyLayer.addTo(map);
|
|
}
|
|
|
|
updateLayerZIndex();
|
|
}
|
|
|
|
// Select a feature from any layer
|
|
function selectFeature(feature, layer, e, layerType = 'diff') {
|
|
// Check if this is a multi-select attempt
|
|
const isMultiSelect = (e.originalEvent && e.originalEvent.shiftKey) || multiSelectMode;
|
|
|
|
// Only allow multi-selection of diff layer features (OSM/county can't be accepted/rejected)
|
|
if (layerType !== 'diff' && isMultiSelect) {
|
|
return; // Block multi-selection of OSM/county layers
|
|
}
|
|
if (isMultiSelect) {
|
|
// Check if already selected
|
|
const alreadySelected = selectedFeatures.includes(feature);
|
|
if (alreadySelected) {
|
|
// Remove from selection
|
|
const index = selectedFeatures.indexOf(feature);
|
|
selectedFeatures.splice(index, 1);
|
|
const layerInfo = selectedLayers[index];
|
|
selectedLayers.splice(index, 1);
|
|
|
|
// Restore original style
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
if (isPoint) {
|
|
const markerStyleFunc = layerType === 'diff' ? diffMarkerStyle :
|
|
layerType === 'osm' ? osmMarkerStyle : countyMarkerStyle;
|
|
layer.setStyle(markerStyleFunc(feature));
|
|
} else {
|
|
const styleFunc = layerType === 'diff' ? diffStyle :
|
|
layerType === 'osm' ? osmStyle : countyStyle;
|
|
layer.setStyle(styleFunc(feature));
|
|
}
|
|
} else {
|
|
// Add to selection
|
|
selectedFeatures.push(feature);
|
|
selectedLayers.push({ layer: layer, type: layerType });
|
|
|
|
// Highlight
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
if (isPoint) {
|
|
layer.setStyle({
|
|
radius: 9,
|
|
fillColor: '#ffc107',
|
|
color: '#ff9800',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1
|
|
});
|
|
} else {
|
|
layer.setStyle({
|
|
weight: 6,
|
|
opacity: 1,
|
|
color: '#ffc107'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update popup with current selection
|
|
if (selectedFeatures.length > 0) {
|
|
showMultiFeaturePopup(e.latlng);
|
|
} else {
|
|
clearSelection();
|
|
}
|
|
} else {
|
|
// Regular click - replace selection
|
|
clearSelection();
|
|
|
|
selectedFeatures = [feature];
|
|
selectedLayers = [{ layer: layer, type: layerType }];
|
|
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
if (isPoint) {
|
|
layer.setStyle({
|
|
radius: 9,
|
|
fillColor: '#ffc107',
|
|
color: '#ff9800',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1
|
|
});
|
|
} else {
|
|
layer.setStyle({
|
|
weight: 6,
|
|
opacity: 1,
|
|
color: '#ffc107'
|
|
});
|
|
}
|
|
|
|
// Show popup for single or multiple features
|
|
showMultiFeaturePopup(e.latlng);
|
|
updateSnapPreview();
|
|
}
|
|
}
|
|
|
|
// Show popup for multiple features (JOSM-style aggregation)
|
|
function showMultiFeaturePopup(latlng) {
|
|
if (selectedFeatures.length === 0) return;
|
|
popupLatlng = latlng;
|
|
updateMultiSelectCount();
|
|
|
|
// Remove old popup without triggering clearSelection
|
|
if (featurePopup) {
|
|
featurePopup.off('remove');
|
|
map.closePopup(featurePopup);
|
|
}
|
|
|
|
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>`;
|
|
|
|
// Aggregate properties JOSM-style
|
|
const allKeys = new Set();
|
|
selectedFeatures.forEach(feature => {
|
|
Object.keys(feature.properties || {}).forEach(key => {
|
|
if (key !== 'removed') allKeys.add(key);
|
|
});
|
|
});
|
|
|
|
// Priority order for display
|
|
const priorityOrder = {
|
|
'addr:housenumber': 0,
|
|
'addr:street': 1,
|
|
'addr:unit': 2,
|
|
'addr:city': 3,
|
|
'addr:postcode': 4,
|
|
'addr:state': 5,
|
|
'name': 10,
|
|
'highway': 11,
|
|
// County road fields
|
|
'Lifecycle': 20,
|
|
'LIFECYCLE': 20,
|
|
'FullStreetN': 21,
|
|
'FullName': 21,
|
|
'StreetClass': 22,
|
|
'STRCLASS': 22,
|
|
'SpeedLimit': 23,
|
|
'SPEEDLIMIT': 23,
|
|
'LeftCity': 24,
|
|
'RightCity': 25,
|
|
'LeftZip': 26,
|
|
'RightZip': 27,
|
|
};
|
|
|
|
const sortedKeys = Array.from(allKeys).sort((a, b) => {
|
|
const aPriority = priorityOrder[a] ?? 999;
|
|
const bPriority = priorityOrder[b] ?? 999;
|
|
if (aPriority !== bPriority) {
|
|
return aPriority - bPriority;
|
|
}
|
|
return a.localeCompare(b);
|
|
});
|
|
|
|
// Compute layer types early so property display can adapt
|
|
const layerTypes = selectedLayers.map(l => l.type);
|
|
const uniqueLayerTypes = [...new Set(layerTypes)];
|
|
const allCounty = uniqueLayerTypes.length === 1 && uniqueLayerTypes[0] === 'county';
|
|
|
|
// For each property, check if all values are the same
|
|
let propsHtml = '<div style="font-size: 11px;">';
|
|
for (const key of sortedKeys) {
|
|
if (key === 'osm_nodes' || key === 'osm_node_versions') continue;
|
|
const values = selectedFeatures
|
|
.map(f => f.properties[key])
|
|
.filter(v => v !== null && v !== undefined);
|
|
|
|
if (values.length === 0) continue;
|
|
|
|
const uniqueValues = [...new Set(values.map(v => String(v)))];
|
|
|
|
let displayValue;
|
|
if (uniqueValues.length === 1) {
|
|
displayValue = uniqueValues[0];
|
|
} else {
|
|
displayValue = `<${uniqueValues.length} different values>`;
|
|
}
|
|
|
|
propsHtml += `<div style="margin: 2px 0;"><strong>${key}:</strong> ${displayValue}</div>`;
|
|
}
|
|
propsHtml += '</div>';
|
|
|
|
if (allCounty) {
|
|
html += `<details style="margin-top:4px;"><summary style="font-size:11px;cursor:pointer;color:#555;">Raw county data</summary>${propsHtml}</details>`;
|
|
} else {
|
|
html += propsHtml;
|
|
}
|
|
if (uniqueLayerTypes.length > 1) {
|
|
html += `<div style="margin-top: 8px;"><strong>Layers:</strong> ${uniqueLayerTypes.join(', ')}</div>`;
|
|
} else {
|
|
html += `<div style="margin-top: 8px;"><strong>Layer:</strong> ${uniqueLayerTypes[0].toUpperCase()}</div>`;
|
|
}
|
|
|
|
// Show status (Added/Removed) for diff layer features
|
|
const hasDiffFeatures = selectedLayers.some(l => l.type === 'diff');
|
|
if (hasDiffFeatures) {
|
|
const addedCount = selectedFeatures.filter(f => !f.properties || f.properties.removed !== true && f.properties.removed !== 'True').length;
|
|
const removedCount = selectedFeatures.filter(f => f.properties && (f.properties.removed === true || f.properties.removed === 'True')).length;
|
|
|
|
if (selectedFeatures.length === 1) {
|
|
// Single selection - show specific status
|
|
const isRemoved = selectedFeatures[0].properties && (selectedFeatures[0].properties.removed === true || selectedFeatures[0].properties.removed === 'True');
|
|
const isConflicted = !isRemoved && selectedFeatures[0].properties &&
|
|
(selectedFeatures[0].properties.conflict === true || selectedFeatures[0].properties.conflict === 'True');
|
|
const statusLabel = isRemoved ? 'Removed (Red)' : (isConflicted ? 'Possibly Conflicted (Yellow)' : 'Added (Green)');
|
|
html += `<div style="margin-top: 8px;"><strong>Status:</strong> ${statusLabel}</div>`;
|
|
if (hasPossibleRename(selectedFeatures[0])) {
|
|
const other = isRemoved ? 'County data has' : 'OSM has';
|
|
const num = selectedFeatures[0].properties['addr:housenumber'] || '';
|
|
html += `<div style="margin-top: 4px; color: ${isRemoved ? '#a00000' : '#1f9e3a'};"><strong>Possible rename:</strong> ${other} "${escapeHtml(String(num + ' ' + selectedFeatures[0].properties.possible_rename).trim())}" nearby</div>`;
|
|
}
|
|
if (!isRemoved && osmVertices.length > 0) {
|
|
html += connectivitySection(selectedFeatures[0]);
|
|
}
|
|
} else if (addedCount > 0 && removedCount > 0) {
|
|
// Mixed selection
|
|
html += `<div style="margin-top: 8px;"><strong>Status:</strong> ${addedCount} added, ${removedCount} removed</div>`;
|
|
} else if (addedCount > 0) {
|
|
html += `<div style="margin-top: 8px;"><strong>Status:</strong> All Added (Green)</div>`;
|
|
} else if (removedCount > 0) {
|
|
html += `<div style="margin-top: 8px;"><strong>Status:</strong> All Removed (Red)</div>`;
|
|
}
|
|
|
|
// One compact connectivity line per selected road
|
|
if (selectedFeatures.length > 1 && osmVertices.length > 0) {
|
|
selectedLayers.forEach(({ type }, i) => {
|
|
if (type !== 'diff') return;
|
|
const feat = selectedFeatures[i];
|
|
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
|
|
if (isRemoved) return;
|
|
html += connectivitySection(feat, i, true);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Show accept/reject status if any are from diff layer
|
|
if (hasDiffFeatures) {
|
|
const acceptedCount = selectedFeatures.filter(f => acceptedFeatures.has(f)).length;
|
|
const rejectedCount = selectedFeatures.filter(f => rejectedFeatures.has(f)).length;
|
|
|
|
if (acceptedCount > 0 || rejectedCount > 0) {
|
|
html += '<div style="margin-top: 8px; font-size: 11px;">';
|
|
const renameCount = selectedFeatures.filter(f => acceptedFeatures.has(f) && f._acceptRename).length;
|
|
if (acceptedCount > 0) html += `<div style="color: #007bff;">✓ ${acceptedCount} accepted${renameCount ? ` (${renameCount} as rename)` : ''}</div>`;
|
|
if (rejectedCount > 0) html += `<div style="color: #4a4a4a;">✗ ${rejectedCount} rejected</div>`;
|
|
html += '</div>';
|
|
}
|
|
|
|
// Show accept/reject buttons only if all selected features are from diff layer
|
|
const allDiff = selectedLayers.every(l => l.type === 'diff');
|
|
if (allDiff) {
|
|
const buttonText = selectedFeatures.length === 1 ? '' : ' All';
|
|
html += '<div style="margin-top: 10px; display: flex; gap: 5px;">';
|
|
html += `<button onclick="acceptAllFeatures()" style="flex: 1; padding: 5px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">Accept${buttonText}</button>`;
|
|
const renamable = selectedFeatures.filter(hasPossibleRename).length;
|
|
if (renamable > 0) {
|
|
const label = selectedFeatures.length === 1 ? 'Accept Rename' : `Accept ${renamable} Rename${renamable === 1 ? '' : 's'}`;
|
|
html += `<button onclick="acceptRenameFeatures()" title="Keep the existing OSM address and change its street to the county's" style="flex: 1; padding: 5px; background: #0b3d91; color: white; border: none; border-radius: 3px; cursor: pointer;">${label}</button>`;
|
|
}
|
|
html += `<button onclick="rejectAllFeatures()" style="flex: 1; padding: 5px; background: #6c757d; color: white; border: none; border-radius: 3px; cursor: pointer;">Reject${buttonText}</button>`;
|
|
html += '</div>';
|
|
html += '<div style="margin-top: 5px;">';
|
|
html += `<button onclick="excludeSelectedFeatures()" style="width: 100%; padding: 5px; background: #fd7e14; color: white; border: none; border-radius: 3px; cursor: pointer;">Exclude${buttonText} from future diffs</button>`;
|
|
html += '</div>';
|
|
}
|
|
}
|
|
|
|
// County road import section
|
|
const isCountySingle = selectedFeatures.length === 1
|
|
&& selectedLayers[0].type === 'county'
|
|
&& selectedFeatures[0].geometry.type === 'LineString';
|
|
if (isCountySingle) {
|
|
const osmTags = countyPropsToOsmTags(selectedFeatures[0].properties);
|
|
const alreadyImported = [...acceptedFeatures].some(f => f._countySource === selectedFeatures[0]);
|
|
html += '<div style="margin-top:10px;border-top:1px solid #ddd;padding-top:8px;font-size:11px;">';
|
|
html += '<strong>Import as:</strong>';
|
|
Object.entries(osmTags).forEach(([k, v]) => {
|
|
html += `<div style="margin:2px 0;"><strong>${k}:</strong> ${v}</div>`;
|
|
});
|
|
if (alreadyImported) {
|
|
html += '<div style="margin-top:6px;color:#28a745;">✓ Already imported</div>';
|
|
} else {
|
|
html += `<button onclick="importCountyFeature()" style="margin-top:8px;width:100%;padding:5px;background:#28a745;color:white;border:none;border-radius:3px;cursor:pointer;">Import as new road</button>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
|
|
// Create popup at click location
|
|
featurePopup = L.popup({
|
|
maxWidth: Math.min(300, window.innerWidth - 60),
|
|
closeButton: true,
|
|
autoClose: false,
|
|
closeOnClick: false
|
|
})
|
|
.setLatLng(latlng)
|
|
.setContent(html)
|
|
.openOn(map);
|
|
|
|
// Handle popup close
|
|
featurePopup.on('remove', function() {
|
|
clearSelection();
|
|
});
|
|
}
|
|
|
|
// Accept all selected features
|
|
function acceptAllFeatures() {
|
|
if (selectedFeatures.length === 0) return;
|
|
|
|
const unpaired = [];
|
|
selectedFeatures.forEach((feature, index) => {
|
|
// Remove from rejected if present
|
|
rejectedFeatures.delete(feature);
|
|
unpaired.push(...clearAcceptedRename(feature));
|
|
|
|
// Add to accepted
|
|
acceptedFeatures.add(feature);
|
|
|
|
// Cache snap endpoints for JOSM export
|
|
const isAdded = !feature.properties || (feature.properties.removed !== true && feature.properties.removed !== 'True');
|
|
if (isAdded && osmVertices.length > 0) cacheSnapInfo(feature);
|
|
|
|
// Update layer style
|
|
const layerInfo = selectedLayers[index];
|
|
if (layerInfo && layerInfo.layer) {
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
if (isPoint) {
|
|
layerInfo.layer.setStyle(diffMarkerStyle(feature));
|
|
} else {
|
|
layerInfo.layer.setStyle(diffStyle(feature));
|
|
}
|
|
}
|
|
});
|
|
|
|
restyleDiffFeatures(unpaired);
|
|
|
|
// Close popup
|
|
if (featurePopup) {
|
|
map.closePopup(featurePopup);
|
|
}
|
|
|
|
// Clear selection
|
|
clearSelection();
|
|
|
|
// Enable save button
|
|
updateSaveButton();
|
|
|
|
showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
|
|
}
|
|
|
|
// Reject all selected features
|
|
function rejectAllFeatures() {
|
|
if (selectedFeatures.length === 0) return;
|
|
|
|
const unpaired = [];
|
|
selectedFeatures.forEach((feature, index) => {
|
|
// Remove from accepted if present
|
|
acceptedFeatures.delete(feature);
|
|
unpaired.push(...clearAcceptedRename(feature));
|
|
|
|
// Add to rejected
|
|
rejectedFeatures.add(feature);
|
|
|
|
// Update layer style
|
|
const layerInfo = selectedLayers[index];
|
|
if (layerInfo && layerInfo.layer) {
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
if (isPoint) {
|
|
layerInfo.layer.setStyle(diffMarkerStyle(feature));
|
|
} else {
|
|
layerInfo.layer.setStyle(diffStyle(feature));
|
|
}
|
|
}
|
|
});
|
|
|
|
restyleDiffFeatures(unpaired);
|
|
|
|
// Close popup
|
|
if (featurePopup) {
|
|
map.closePopup(featurePopup);
|
|
}
|
|
|
|
// Clear selection
|
|
clearSelection();
|
|
|
|
// Enable save button
|
|
updateSaveButton();
|
|
|
|
showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
|
|
}
|
|
|
|
// Exclude selected features by name/street and refresh diff layer
|
|
async function excludeSelectedFeatures() {
|
|
if (selectedFeatures.length === 0) return;
|
|
|
|
const toExclude = [];
|
|
const seen = new Set();
|
|
|
|
for (const feature of selectedFeatures) {
|
|
const props = feature.properties || {};
|
|
let field, value;
|
|
|
|
if (props['name']) {
|
|
field = 'name';
|
|
value = props['name'];
|
|
} else if (props['addr:street']) {
|
|
field = 'addr:street';
|
|
value = props['addr:street'];
|
|
} else {
|
|
continue;
|
|
}
|
|
|
|
const key = `${field}\0${value}`;
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
toExclude.push({ field, value });
|
|
}
|
|
}
|
|
|
|
if (toExclude.length === 0) {
|
|
showStatus('No name or street property found to exclude by.', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch('/api/exclusions/add', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ exclusions: toExclude })
|
|
});
|
|
const data = await resp.json();
|
|
if (data.error) {
|
|
if (resp.status === 401) {
|
|
showStatus('Not authenticated — please log in to exclude features.', 'error');
|
|
} else {
|
|
showStatus(`Error: ${data.error}`, 'error');
|
|
}
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
showStatus(`Error: ${err.message}`, 'error');
|
|
return;
|
|
}
|
|
|
|
// Update local list and refresh
|
|
for (const e of toExclude) {
|
|
if (!exclusions.some(ex => ex.field === e.field && ex.value === e.value)) {
|
|
exclusions.push(e);
|
|
}
|
|
}
|
|
|
|
if (featurePopup) map.closePopup(featurePopup);
|
|
clearSelection();
|
|
createDiffLayer();
|
|
|
|
const names = toExclude.map(e => e.value).join(', ');
|
|
showStatus(`Excluded: ${names}`, 'success');
|
|
}
|
|
|
|
// ---- Address renames ----
|
|
// compare-addresses.py pairs a new county address with a nearby OSM address
|
|
// that has the same number on a differently named street, giving both the
|
|
// same 'rename_pair'. Accepting the rename keeps the OSM element and exports
|
|
// only its corrected addr:street, instead of adding one node and deleting the
|
|
// other.
|
|
|
|
function renamePartner(feature) {
|
|
const id = feature.properties && feature.properties.rename_pair;
|
|
if (id === undefined || id === null || !diffData) return null;
|
|
return diffData.features.find(f => f !== feature && f.properties && f.properties.rename_pair === id) || null;
|
|
}
|
|
|
|
function restyleDiffFeatures(features) {
|
|
if (!diffLayer || features.length === 0) return;
|
|
const set = new Set(features);
|
|
diffLayer.eachLayer(l => {
|
|
if (set.has(l.feature)) l.setStyle(l.feature.geometry.type === 'Point' ? diffMarkerStyle(l.feature) : diffStyle(l.feature));
|
|
});
|
|
}
|
|
|
|
// Undo an accepted rename on both sides of its pair (the other side was only
|
|
// accepted as part of the rename). Returns the other side if it changed.
|
|
function clearAcceptedRename(feature) {
|
|
if (!feature._acceptRename) return [];
|
|
delete feature._acceptRename;
|
|
const partner = renamePartner(feature);
|
|
if (partner && partner._acceptRename) {
|
|
delete partner._acceptRename;
|
|
acceptedFeatures.delete(partner);
|
|
return [partner];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function acceptRenameFeatures() {
|
|
const touched = [];
|
|
let accepted = 0, missingId = 0;
|
|
selectedFeatures.forEach(feature => {
|
|
if (!hasPossibleRename(feature)) return;
|
|
const partner = renamePartner(feature);
|
|
if (!partner) return;
|
|
const osmSide = isRemovedFeature(feature) ? feature : partner;
|
|
if (!osmSide.properties.osm_id) { missingId++; return; }
|
|
[feature, partner].forEach(f => {
|
|
rejectedFeatures.delete(f);
|
|
acceptedFeatures.add(f);
|
|
f._acceptRename = true;
|
|
touched.push(f);
|
|
});
|
|
accepted++;
|
|
});
|
|
|
|
if (featurePopup) map.closePopup(featurePopup);
|
|
clearSelection();
|
|
restyleDiffFeatures(touched);
|
|
updateSaveButton();
|
|
|
|
if (missingId > 0) {
|
|
showStatus(`${missingId} rename${missingId === 1 ? '' : 's'} not accepted: the OSM addresses have no element ids. Re-run "Download OSM data" and "Diff vs OSM" for addresses.`, 'error');
|
|
} else {
|
|
showStatus(`${accepted} rename${accepted === 1 ? '' : 's'} accepted; ${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
|
|
}
|
|
}
|
|
|
|
// Expose functions globally for onclick handlers
|
|
window.acceptRenameFeatures = acceptRenameFeatures;
|
|
window.acceptAllFeatures = acceptAllFeatures;
|
|
window.rejectAllFeatures = rejectAllFeatures;
|
|
window.excludeSelectedFeatures = excludeSelectedFeatures;
|
|
|
|
// ---- JOSM Export: OSM node index ----
|
|
|
|
function buildOsmNodeIndex() {
|
|
osmVertices = [];
|
|
osmNodeById.clear();
|
|
osmCoordToNodeId.clear();
|
|
osmNodeVersionById.clear();
|
|
if (!osmData) return;
|
|
osmData.features.forEach((feat, wi) => {
|
|
const nodeIds = feat.properties.osm_nodes;
|
|
const nodeVersions = feat.properties.osm_node_versions;
|
|
const name = feat.properties.name || feat.properties.highway || '';
|
|
const coords = feat.geometry && feat.geometry.type === 'LineString' && feat.geometry.coordinates;
|
|
if (!coords) return;
|
|
coords.forEach((coord, ci) => {
|
|
const nid = nodeIds ? nodeIds[ci] : null;
|
|
const key = coord[0].toFixed(7) + ',' + coord[1].toFixed(7);
|
|
if (nid) {
|
|
osmNodeById.set(nid, coord);
|
|
osmCoordToNodeId.set(key, nid);
|
|
if (nodeVersions && nodeVersions[ci]) osmNodeVersionById.set(nid, nodeVersions[ci]);
|
|
}
|
|
osmVertices.push({ nodeId: nid, coord, name, wayIdx: wi, posInWay: ci, wayLen: coords.length });
|
|
});
|
|
});
|
|
}
|
|
|
|
function findNearestOsmVertex(coord, tol) {
|
|
tol = tol !== undefined ? tol : SNAP_TOL;
|
|
const [lon, lat] = coord;
|
|
let best = null, bestDist = tol;
|
|
for (const v of osmVertices) {
|
|
const d = Math.hypot(v.coord[0] - lon, v.coord[1] - lat);
|
|
if (d < bestDist) { bestDist = d; best = { ...v, dist: d }; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function projectOnSeg(pt, a, b) {
|
|
const dx = b[0] - a[0], dy = b[1] - a[1];
|
|
const len2 = dx * dx + dy * dy;
|
|
if (len2 === 0) return { t: 0, coord: a };
|
|
const t = Math.max(0, Math.min(1, ((pt[0] - a[0]) * dx + (pt[1] - a[1]) * dy) / len2));
|
|
return { t, coord: [a[0] + t * dx, a[1] + t * dy] };
|
|
}
|
|
|
|
function closestPointOnWay(point, coords, tol) {
|
|
let best = null, bestDist = tol !== undefined ? tol : Infinity;
|
|
for (let i = 0; i < coords.length - 1; i++) {
|
|
const { t, coord } = projectOnSeg(point, coords[i], coords[i + 1]);
|
|
const d = Math.hypot(coord[0] - point[0], coord[1] - point[1]);
|
|
if (d < bestDist) {
|
|
bestDist = d;
|
|
best = { segIdx: i, t, coord, dist: d };
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
// Find closest point on any OSM way segment (not just vertices).
|
|
// Returns { feat, segIdx, t, coord, dist } or null.
|
|
function findNearestOsmWayPoint(coord, tol) {
|
|
if (!osmData) return null;
|
|
let best = null, bestDist = tol !== undefined ? tol : SNAP_TOL;
|
|
osmData.features.forEach(feat => {
|
|
if (!feat.geometry || feat.geometry.type !== 'LineString') return;
|
|
const hit = closestPointOnWay(coord, feat.geometry.coordinates, bestDist);
|
|
if (hit && hit.dist < bestDist) {
|
|
bestDist = hit.dist;
|
|
best = { feat, ...hit };
|
|
}
|
|
});
|
|
return best;
|
|
}
|
|
|
|
function segIntersect(p1, p2, p3, p4) {
|
|
const d1x = p2[0] - p1[0], d1y = p2[1] - p1[1];
|
|
const d2x = p4[0] - p3[0], d2y = p4[1] - p3[1];
|
|
const cross = d1x * d2y - d1y * d2x;
|
|
if (Math.abs(cross) < 1e-12) return null;
|
|
const dx = p3[0] - p1[0], dy = p3[1] - p1[1];
|
|
const t = (dx * d2y - dy * d2x) / cross;
|
|
const u = (dx * d1y - dy * d1x) / cross;
|
|
if (t < 0 || t > 1 || u < 0 || u > 1) return null;
|
|
return [p1[0] + t * d1x, p1[1] + t * d1y];
|
|
}
|
|
|
|
// Flags added diff roads that cross an existing OSM road with the same name,
|
|
// which usually means the road was renamed or rerouted rather than being new.
|
|
// Same rule as the "possible rename/reroute" hint in the connectivity popup.
|
|
function markPossibleRenames() {
|
|
if (!diffData || !diffData.features) return;
|
|
const lineBbox = coords => {
|
|
let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity;
|
|
coords.forEach(([lon, lat]) => {
|
|
if (lon < minLon) minLon = lon; if (lon > maxLon) maxLon = lon;
|
|
if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat;
|
|
});
|
|
return { minLon, maxLon, minLat, maxLat };
|
|
};
|
|
// Named OSM ways, grouped by lowercased name
|
|
const osmByName = new Map();
|
|
((osmData && osmData.features) || []).forEach(feat => {
|
|
const coords = feat.geometry && feat.geometry.type === 'LineString' && feat.geometry.coordinates;
|
|
const name = feat.properties && feat.properties.name;
|
|
if (!coords || coords.length < 2 || !name) return;
|
|
const key = name.toLowerCase();
|
|
if (!osmByName.has(key)) osmByName.set(key, []);
|
|
osmByName.get(key).push({ coords, bbox: lineBbox(coords) });
|
|
});
|
|
diffData.features.forEach(feature => {
|
|
feature._possibleRename = false;
|
|
const props = feature.properties || {};
|
|
const coords = feature.geometry && feature.geometry.type === 'LineString' && feature.geometry.coordinates;
|
|
if (!coords || coords.length < 2 || !props.name || props.removed === true || props.removed === 'True') return;
|
|
const ways = osmByName.get(props.name.toLowerCase());
|
|
if (!ways) return;
|
|
const b = lineBbox(coords);
|
|
feature._possibleRename = ways.some(way =>
|
|
way.bbox.maxLon >= b.minLon - SNAP_TOL && way.bbox.minLon <= b.maxLon + SNAP_TOL &&
|
|
way.bbox.maxLat >= b.minLat - SNAP_TOL && way.bbox.minLat <= b.maxLat + SNAP_TOL &&
|
|
coords.some((p, i) => i < coords.length - 1 && way.coords.some((q, j) =>
|
|
j < way.coords.length - 1 && segIntersect(p, coords[i + 1], q, way.coords[j + 1]))));
|
|
});
|
|
}
|
|
|
|
function findOsmCrossings(newCoords) {
|
|
if (!osmData) return [];
|
|
let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity;
|
|
newCoords.forEach(([lon, lat]) => {
|
|
if (lon < minLon) minLon = lon; if (lon > maxLon) maxLon = lon;
|
|
if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat;
|
|
});
|
|
const pad = SNAP_TOL;
|
|
const results = [];
|
|
osmData.features.forEach(feat => {
|
|
const coords = feat.geometry && feat.geometry.type === 'LineString' && feat.geometry.coordinates;
|
|
if (!coords || coords.length < 2) return;
|
|
let fMinLon = Infinity, fMaxLon = -Infinity, fMinLat = Infinity, fMaxLat = -Infinity;
|
|
coords.forEach(([lon, lat]) => {
|
|
if (lon < fMinLon) fMinLon = lon; if (lon > fMaxLon) fMaxLon = lon;
|
|
if (lat < fMinLat) fMinLat = lat; if (lat > fMaxLat) fMaxLat = lat;
|
|
});
|
|
if (fMaxLon < minLon - pad || fMinLon > maxLon + pad ||
|
|
fMaxLat < minLat - pad || fMinLat > maxLat + pad) return;
|
|
for (let i = 0; i < newCoords.length - 1; i++) {
|
|
for (let j = 0; j < coords.length - 1; j++) {
|
|
const pt = segIntersect(newCoords[i], newCoords[i + 1], coords[j], coords[j + 1]);
|
|
if (pt) {
|
|
results.push({ feature: feat, crossPoint: pt, name: feat.properties.name || feat.properties.highway || '' });
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
return results;
|
|
}
|
|
|
|
function cacheSnapInfo(feature) {
|
|
if (!feature.geometry || feature.geometry.type !== 'LineString') return;
|
|
const coords = feature.geometry.coordinates;
|
|
feature._snapA = findNearestOsmVertex(coords[0]);
|
|
feature._snapB = findNearestOsmVertex(coords[coords.length - 1]);
|
|
feature._segSnapA = feature._snapA ? null : findNearestOsmWayPoint(coords[0], SNAP_TOL * 3);
|
|
feature._segSnapB = feature._snapB ? null : findNearestOsmWayPoint(coords[coords.length - 1], SNAP_TOL * 3);
|
|
feature._snapTol = SNAP_TOL;
|
|
feature._snappedCoords = [
|
|
feature._snapA ? feature._snapA.coord : coords[0],
|
|
...coords.slice(1, -1),
|
|
feature._snapB ? feature._snapB.coord : coords[coords.length - 1]
|
|
];
|
|
}
|
|
|
|
function precalculateSnaps() {
|
|
if (!diffData || osmVertices.length === 0) return;
|
|
diffData.features.forEach(feat => {
|
|
if (!feat.geometry || feat.geometry.type !== 'LineString') return;
|
|
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
|
|
if (!isRemoved) cacheSnapInfo(feat);
|
|
});
|
|
}
|
|
|
|
// ---- County road tag conversion (mirrors diff-highways.py logic) ----
|
|
|
|
function formatStreetName(s) {
|
|
if (!s) return s;
|
|
const abbrevs = {
|
|
'N':'North','NE':'Northeast','E':'East','SE':'Southeast','S':'South',
|
|
'SW':'Southwest','W':'West','NW':'Northwest',
|
|
'ST':'Street','AVE':'Avenue','AV':'Avenue','BLVD':'Boulevard','BV':'Boulevard',
|
|
'CIR':'Circle','CT':'Court','DR':'Drive','HWY':'Highway','HW':'Highway',
|
|
'LN':'Lane','LOOP':'Loop','LP':'Loop','PKWY':'Parkway','PL':'Place',
|
|
'PT':'Point','RD':'Road','TRL':'Trail','TR':'Trail','WAY':'Way','WY':'Way',
|
|
'XING':'Crossing','CRK':'Creek','RDG':'Ridge','RUN':'Run','GLN':'Glenn',
|
|
'GRV':'Grove','HL':'Hill','HTS':'Heights','MNR':'Manor','MT':'Mount',
|
|
'BND':'Bend','CRST':'Crest','CV':'Curve','CURV':'Curve','FLDS':'Fields',
|
|
'HOLW':'Hollow','LNDG':'Landing','MTN':'Mountain','PARK':'Park','PASS':'Pass',
|
|
'PATH':'Path','PLZ':'Plaza','SQ':'Square','TER':'Terrace','TRCE':'Terrace',
|
|
'TPKE':'Turnpike','VW':'View','WALK':'Walk','ALY':'Alley','BLF':'Bluff',
|
|
'COR':'Corner','SHRS':'Shores','SR':'SR','US':'US',
|
|
};
|
|
const parts = s.trim().split(/\s+/);
|
|
if (parts[0] && parts[0].toUpperCase() === 'ST' && parts.length > 1) parts[0] = 'Saint';
|
|
if (parts[0] && parts[0].toUpperCase() === 'CR') parts[0] = 'County Road';
|
|
return parts.map(p => {
|
|
const up = p.toUpperCase();
|
|
if (abbrevs[up]) return abbrevs[up];
|
|
if (/^[0-9]{2,4}[A-Za-z]$/.test(p)) return p;
|
|
return p.charAt(0).toUpperCase() + p.slice(1).toLowerCase();
|
|
}).join(' ');
|
|
}
|
|
|
|
function getLakeHighwayType(sc) {
|
|
return { ALLEY:'alley', LOCAL:'residential', MAJOR:'trunk', 'MEDIAN CUT':'primary_link',
|
|
OTHER:'unclassified', PRIMARY:'primary', PRIVATE:'service', RAMP:'trunk_link',
|
|
SECONDARY:'secondary', 'TURN LANE':'primary_link', 'VEHICULAR TRAIL':'track' }[sc] || 'residential';
|
|
}
|
|
|
|
function getSumterHighwayType(rc) {
|
|
if (!rc) return 'residential';
|
|
const v = String(rc);
|
|
if (v.startsWith('PRIMARY')) return 'trunk';
|
|
if (v.startsWith('MAJOR')) return 'primary';
|
|
return 'residential';
|
|
}
|
|
|
|
function countyPropsToOsmTags(props) {
|
|
const tags = { surface: 'asphalt' };
|
|
const isSumter = 'NAME' in props && 'RoadClass' in props;
|
|
const isLake = 'FullStreet' in props;
|
|
if (isSumter) {
|
|
if (props.NAME) tags.name = formatStreetName(props.NAME);
|
|
if (props.SpeedLimit != null) tags.maxspeed = `${props.SpeedLimit} mph`;
|
|
tags.highway = getSumterHighwayType(props.RoadClass);
|
|
if (props.OneWayCode === 'F') tags.oneway = 'yes';
|
|
else if (props.OneWayCode === 'T') tags.oneway = '-1';
|
|
const laneCount = parseInt(props.LANES);
|
|
if (laneCount > 0) tags.lanes = String(laneCount);
|
|
} else if (isLake) {
|
|
if (props.FullStreet) tags.name = formatStreetName(props.FullStreet);
|
|
if (props.SpeedLimit != null) tags.maxspeed = `${props.SpeedLimit} mph`;
|
|
tags.highway = getLakeHighwayType(props.StreetClas);
|
|
if (props.Oneway === 'FT') tags.oneway = 'yes';
|
|
else if (props.Oneway === 'TF') tags.oneway = '-1';
|
|
const laneCount = parseInt(props.NumberOfLa);
|
|
if (laneCount > 0) tags.lanes = String(laneCount);
|
|
} else {
|
|
const name = props.NAME || props.FullStreet || props.name;
|
|
if (name) tags.name = formatStreetName(name);
|
|
if (props.SpeedLimit != null) tags.maxspeed = `${props.SpeedLimit} mph`;
|
|
tags.highway = 'residential';
|
|
}
|
|
if (!tags.highway) tags.highway = 'residential';
|
|
return tags;
|
|
}
|
|
|
|
// ---- Snap preview markers ----
|
|
|
|
// Draw snap preview for one feature onto snapPreviewLayer.
|
|
// interactive=true also adds clickable endpoint dots (selected or hovered features).
|
|
function drawSnapPreviewForFeature(feature, interactive, target = snapPreviewLayer) {
|
|
if (!feature || !feature.geometry || feature.geometry.type !== 'LineString') return;
|
|
const rawCoords = feature.geometry.coordinates;
|
|
|
|
const ends = [0, rawCoords.length - 1].map(i => {
|
|
const isStart = i === 0;
|
|
const snap = isStart ? feature._snapA : feature._snapB;
|
|
const enabledFlag = isStart ? '_snapAEnabled' : '_snapBEnabled';
|
|
const isEnabled = feature[enabledFlag] !== false;
|
|
const segSnap = isStart ? feature._segSnapA : feature._segSnapB;
|
|
const rawCoord = rawCoords[i];
|
|
let finalCoord = rawCoord;
|
|
if (isEnabled && snap) finalCoord = snap.coord;
|
|
else if (isEnabled && segSnap) finalCoord = segSnap.coord;
|
|
return { isStart, snap, segSnap, enabledFlag, isEnabled, rawCoord, finalCoord };
|
|
});
|
|
|
|
// Draw only the connector segment where an endpoint actually moves (raw → snapped).
|
|
// Don't redraw the full road — that duplicates what's already visible in the diff layer.
|
|
ends.forEach(({ rawCoord, finalCoord }) => {
|
|
if (Math.hypot(finalCoord[0] - rawCoord[0], finalCoord[1] - rawCoord[1]) < 1e-8) return;
|
|
L.polyline([[rawCoord[1], rawCoord[0]], [finalCoord[1], finalCoord[0]]], {
|
|
color: '#ff6b00', weight: 3, opacity: 0.85, dashArray: '7,5', pane: 'snapPane'
|
|
}).addTo(target);
|
|
});
|
|
|
|
if (!interactive) return;
|
|
|
|
ends.forEach(({ isStart, snap, segSnap, enabledFlag, isEnabled, finalCoord }) => {
|
|
// Orange: this end snaps (matches the dashed connector); red: its snap
|
|
// is turned off; gray: nothing nearby to snap to.
|
|
const canToggle = !!(snap || segSnap);
|
|
const color = !canToggle ? '#6c757d' : (isEnabled ? '#ff6b00' : '#dc3545');
|
|
const endLabel = isStart ? 'Start' : 'End';
|
|
|
|
const marker = L.circleMarker([finalCoord[1], finalCoord[0]], {
|
|
radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.9, pane: 'snapPane'
|
|
});
|
|
marker.on('mouseover', () => {
|
|
marker.setStyle({ radius: 11, weight: 3 });
|
|
if (target === hoverSnapLayer) clearTimeout(hoverSnapHideTimer);
|
|
});
|
|
marker.on('mouseout', () => {
|
|
marker.setStyle({ radius: 8, weight: 2 });
|
|
if (target === hoverSnapLayer) scheduleHideHoverSnap();
|
|
});
|
|
|
|
if (canToggle) {
|
|
const target = snap ? `"${snap.name || 'unnamed road'}"` : 'the nearby road';
|
|
marker.bindTooltip(
|
|
isEnabled ? `${endLabel} snaps to ${target} — click to turn off`
|
|
: `${endLabel} snap is off — click to snap to ${target}`,
|
|
{ sticky: true }
|
|
);
|
|
marker.on('click', function(e) {
|
|
L.DomEvent.stopPropagation(e);
|
|
feature[enabledFlag] = !isEnabled;
|
|
updateSnapPreview();
|
|
if (hoverSnapFeature === feature) showHoverSnapDots(feature);
|
|
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
|
});
|
|
} else {
|
|
marker.bindTooltip(`${endLabel}: no nearby road to snap to`, { sticky: true });
|
|
}
|
|
target.addLayer(marker);
|
|
});
|
|
}
|
|
|
|
function isRemovedFeature(feat) {
|
|
return !!(feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True'));
|
|
}
|
|
|
|
// Show clickable end dots for a hovered added road. Selected roads already
|
|
// have theirs on snapPreviewLayer.
|
|
function showHoverSnapDots(feature) {
|
|
if (!hoverSnapLayer) return;
|
|
clearTimeout(hoverSnapHideTimer);
|
|
hoverSnapLayer.clearLayers();
|
|
hoverSnapFeature = null;
|
|
if (osmVertices.length === 0 || !feature.geometry || feature.geometry.type !== 'LineString') return;
|
|
if (isRemovedFeature(feature) || selectedFeatures.includes(feature)) return;
|
|
if (feature._snapA === undefined || feature._snapTol !== SNAP_TOL) cacheSnapInfo(feature);
|
|
hoverSnapFeature = feature;
|
|
drawSnapPreviewForFeature(feature, true, hoverSnapLayer);
|
|
}
|
|
|
|
// Linger briefly so the mouse can travel from the road onto one of its dots.
|
|
function scheduleHideHoverSnap() {
|
|
clearTimeout(hoverSnapHideTimer);
|
|
hoverSnapHideTimer = setTimeout(() => {
|
|
if (hoverSnapLayer) hoverSnapLayer.clearLayers();
|
|
hoverSnapFeature = null;
|
|
}, 700);
|
|
}
|
|
|
|
// Redraw snap preview layer:
|
|
// - all accepted added features get a preview line when the checkbox is on
|
|
// - the currently selected diff added feature always gets the interactive dot markers
|
|
function updateSnapPreview() {
|
|
if (!snapPreviewLayer) return;
|
|
snapPreviewLayer.clearLayers();
|
|
// A road that just became selected gets its dots from here instead.
|
|
if (hoverSnapFeature && selectedFeatures.includes(hoverSnapFeature)) {
|
|
hoverSnapLayer.clearLayers();
|
|
hoverSnapFeature = null;
|
|
}
|
|
if (osmVertices.length === 0) return;
|
|
|
|
const showAll = document.getElementById('showSnapPreview')?.checked;
|
|
const selectedSet = new Set(selectedFeatures);
|
|
|
|
if (showAll) {
|
|
acceptedFeatures.forEach(feat => {
|
|
if (!feat.geometry || feat.geometry.type !== 'LineString') return;
|
|
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
|
|
if (isRemoved || selectedSet.has(feat)) return;
|
|
drawSnapPreviewForFeature(feat, false);
|
|
});
|
|
}
|
|
|
|
// Selected diff added features always get interactive dots regardless of checkbox
|
|
selectedLayers.forEach(({ type }, i) => {
|
|
if (type !== 'diff') return;
|
|
const feat = selectedFeatures[i];
|
|
if (!feat || !feat.geometry || feat.geometry.type !== 'LineString') return;
|
|
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
|
|
if (!isRemoved) drawSnapPreviewForFeature(feat, true);
|
|
});
|
|
}
|
|
|
|
// Hovering a road's line in a multi-select popup highlights that road on the map.
|
|
window.highlightSelectedRoad = function(idx, on) {
|
|
const entry = selectedLayers[idx];
|
|
if (!entry || !entry.layer.setStyle) return;
|
|
entry.layer.setStyle(on ? { weight: 10, opacity: 1, color: '#00b8d4' }
|
|
: { weight: 6, opacity: 1, color: '#ffc107' });
|
|
if (on && entry.layer.bringToFront) entry.layer.bringToFront();
|
|
};
|
|
|
|
window.importCountyFeature = function() {
|
|
if (selectedFeatures.length !== 1 || selectedLayers[0].type !== 'county') return;
|
|
const source = selectedFeatures[0];
|
|
if (source.geometry.type !== 'LineString') return;
|
|
const osmTags = countyPropsToOsmTags(source.properties);
|
|
const synthetic = {
|
|
type: 'Feature',
|
|
geometry: JSON.parse(JSON.stringify(source.geometry)),
|
|
properties: osmTags,
|
|
_countySource: source
|
|
};
|
|
importedCountyFeatures.add(source);
|
|
acceptedFeatures.add(synthetic);
|
|
cacheSnapInfo(synthetic);
|
|
if (countyLayer) {
|
|
countyLayer.eachLayer(l => { if (l.feature === source) l.setStyle(countyStyle(source)); });
|
|
}
|
|
updateSnapPreview();
|
|
showStatus(`Imported "${osmTags.name || 'unnamed'}" from county data`, 'success');
|
|
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
|
};
|
|
|
|
// Connectivity summary for an added road. Snapping at each end is toggled by
|
|
// clicking the end's dot on the map, not from here. With `compact` (used for
|
|
// each road in a multi-selection) it is one line; hovering that line
|
|
// highlights the road on the map.
|
|
function connectivitySection(feature, idx, compact) {
|
|
if (idx === undefined) idx = 0;
|
|
if (!feature.geometry || feature.geometry.type !== 'LineString') return '';
|
|
if (osmVertices.length > 0 && (feature._snapA === undefined || feature._snapTol !== SNAP_TOL)) {
|
|
cacheSnapInfo(feature);
|
|
}
|
|
const coords = feature.geometry.coordinates;
|
|
|
|
// Check if an endpoint connects to another new diff road (shares coordinate within tolerance)
|
|
function findDiffConnection(coord) {
|
|
if (!diffData) return null;
|
|
let best = null, bestDist = SNAP_TOL;
|
|
diffData.features.forEach(f => {
|
|
if (f === feature || !f.geometry || f.geometry.type !== 'LineString') return;
|
|
const fc = f.geometry.coordinates;
|
|
[fc[0], fc[fc.length - 1]].forEach(ep => {
|
|
const d = Math.hypot(ep[0] - coord[0], ep[1] - coord[1]);
|
|
if (d < bestDist) { bestDist = d; best = f.properties.name || 'new road'; }
|
|
});
|
|
});
|
|
return best;
|
|
}
|
|
|
|
// {icon, color, text, short} describing one end of the road
|
|
function endpointInfo(isStart) {
|
|
const snap = isStart ? feature._snapA : feature._snapB;
|
|
const segSnap = isStart ? feature._segSnapA : feature._segSnapB;
|
|
const isEnabled = feature[isStart ? '_snapAEnabled' : '_snapBEnabled'] !== false;
|
|
if (snap || segSnap) {
|
|
const m = Math.round((snap || segSnap).dist * 111320);
|
|
const target = snap
|
|
? `${snap.posInWay === 0 || snap.posInWay === snap.wayLen - 1 ? 'endpoint' : 'midpoint'} of "${snap.name || 'unnamed road'}"`
|
|
: `"${(segSnap.feat.properties && (segSnap.feat.properties.name || segSnap.feat.properties.highway)) || 'unnamed road'}" (new node)`;
|
|
if (!isEnabled) {
|
|
return { icon: '✗', color: '#dc3545', short: 'snap off', text: `snap off (would join ${target}, ${m} m)` };
|
|
}
|
|
return { icon: '✓', color: '#155724', short: 'snaps', text: `snaps to ${target} (${m} m)` };
|
|
}
|
|
const diffConn = findDiffConnection(isStart ? coords[0] : coords[coords.length - 1]);
|
|
if (diffConn) {
|
|
return { icon: '✓', color: '#155724', short: 'joins new', text: `connects to new "${diffConn}"` };
|
|
}
|
|
return { icon: '⚠', color: '#856404', short: 'dangling', text: 'no nearby road — dangling' };
|
|
}
|
|
|
|
const ends = [endpointInfo(true), endpointInfo(false)];
|
|
const crossings = findOsmCrossings(coords);
|
|
const newName = feature.properties && feature.properties.name;
|
|
const isRename = c => newName && c.name && c.name.toLowerCase() === newName.toLowerCase();
|
|
|
|
if (compact) {
|
|
const name = newName || `Road ${idx + 1}`;
|
|
const title = ['Start', 'End'].map((l, i) => `${l}: ${ends[i].text}`).join('\n').replace(/—/g, '-').replace(/&[^;]+;/g, '');
|
|
let line = ['S', 'E'].map((l, i) =>
|
|
`<span style="color:${ends[i].color};white-space:nowrap;">${ends[i].icon} ${l}: ${ends[i].short}</span>`).join(' · ');
|
|
if (crossings.length) line += ` · <span style="color:#004085;white-space:nowrap;">⇄ ${crossings.length}</span>`;
|
|
if (crossings.some(isRename)) line += ' · <em style="color:#1f9e3a;">possible rename</em>';
|
|
return `<div class="conn-row" title="${title.replace(/"/g, '"')}" ` +
|
|
`onmouseenter="highlightSelectedRoad(${idx}, true)" onmouseleave="highlightSelectedRoad(${idx}, false)" ` +
|
|
`style="margin-top:3px;padding:3px 4px;border-radius:3px;font-size:11px;cursor:default;">` +
|
|
`<div style="font-weight:bold;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${name}</div>${line}</div>`;
|
|
}
|
|
|
|
let html = '<div style="margin-top:8px;font-size:11px;">';
|
|
html += '<strong>Connectivity:</strong>';
|
|
['Start', 'End'].forEach((label, i) => {
|
|
html += `<div style="color:${ends[i].color};">${ends[i].icon} ${label}: ${ends[i].text}</div>`;
|
|
});
|
|
crossings.slice(0, 3).forEach(c => {
|
|
const hint = isRename(c) ? ' — <em>possible rename/reroute</em>' : '';
|
|
html += `<div style="color:#004085;">⇄ Crosses "${c.name || 'unnamed'}"${hint}</div>`;
|
|
});
|
|
if (crossings.length > 3) html += `<div style="color:#004085;">… and ${crossings.length - 3} more</div>`;
|
|
if (feature._snapA || feature._segSnapA || feature._snapB || feature._segSnapB) {
|
|
html += '<div style="color:#777;margin-top:2px;">Click an orange/red end dot on the map to toggle its snap.</div>';
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// ---- JOSM Export: removal plans ----
|
|
|
|
function buildNodeRefCount() {
|
|
const counts = new Map();
|
|
if (!osmData) return counts;
|
|
osmData.features.forEach(feat => {
|
|
(feat.properties.osm_nodes || []).forEach(nid => {
|
|
counts.set(nid, (counts.get(nid) || 0) + 1);
|
|
});
|
|
});
|
|
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', 'osm_members',
|
|
// compare-addresses.py annotations
|
|
'conflict', 'county_city', 'county_postal_community', 'possible_rename', 'rename_pair',
|
|
]);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// A tag-only modify of an existing OSM element (from an address download,
|
|
// which keeps each element's id, version, node refs and members).
|
|
function elementEdit(feat, tags) {
|
|
const p = feat.properties || {};
|
|
return {
|
|
type: p.osm_type || 'node', id: p.osm_id, version: p.osm_version || 1,
|
|
coord: feat.geometry.coordinates, nodes: p.osm_nodes, members: p.osm_members, tags
|
|
};
|
|
}
|
|
|
|
function computeRemovalPlan(removedFeat, acceptedAddedFeats, nodeRefCount) {
|
|
const props = removedFeat.properties || {};
|
|
const osmId = props.osm_id || props['@id'];
|
|
const osmVersion = props.osm_version || 1;
|
|
const osmNodes = props.osm_nodes;
|
|
const wayCoords = removedFeat.geometry && removedFeat.geometry.coordinates;
|
|
|
|
if (removedFeat.geometry && removedFeat.geometry.type === 'Point') {
|
|
// An address on a building/area, or on a node that is also something
|
|
// else (a shop, a POI), loses only its addr:* tags; the element stays.
|
|
const elType = props.osm_type || 'node';
|
|
const tags = osmTagsFrom(props);
|
|
if (elType !== 'node' || Object.keys(tags).some(k => !k.startsWith('addr:'))) {
|
|
const kept = Object.fromEntries(Object.entries(tags).filter(([k]) => !k.startsWith('addr:')));
|
|
return { type: 'stripAddress', osmId, edit: elementEdit(removedFeat, kept),
|
|
description: `Remove address tags from ${elType}${osmId ? ' ' + osmId : ''}` };
|
|
}
|
|
return { type: 'deleteNode', osmId, osmVersion, description: `Delete node${osmId ? ' ' + osmId : ''}` };
|
|
}
|
|
|
|
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)` };
|
|
}
|
|
|
|
// Find endpoints of accepted added features that land on this old way
|
|
let splitPoints = [];
|
|
acceptedAddedFeats.forEach(addedFeat => {
|
|
const snappedCoords = addedFeat._snappedCoords || addedFeat.geometry.coordinates;
|
|
[snappedCoords[0], snappedCoords[snappedCoords.length - 1]].forEach(ep => {
|
|
const hit = closestPointOnWay(ep, wayCoords, SNAP_TOL * 3);
|
|
if (hit) {
|
|
splitPoints.push({
|
|
segIdx: hit.segIdx,
|
|
localT: hit.t,
|
|
t_overall: (hit.segIdx + hit.t) / (wayCoords.length - 1),
|
|
coord: hit.coord,
|
|
newRoadName: (addedFeat.properties && addedFeat.properties.name) || 'new road'
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
if (splitPoints.length === 0) {
|
|
const deleteNodeIds = osmNodes.slice(1, -1).filter(nid => (nodeRefCount.get(nid) || 0) <= 1);
|
|
return { type: 'delete', osmId, osmVersion, wayCoords, osmNodes, tags, deleteNodeIds, description: `Delete way${osmId ? ' ' + osmId : ''}` };
|
|
}
|
|
|
|
splitPoints.sort((a, b) => a.t_overall - b.t_overall);
|
|
// Deduplicate very close split points
|
|
splitPoints = splitPoints.filter((sp, i) => i === 0 || Math.abs(sp.t_overall - splitPoints[i - 1].t_overall) > 0.01);
|
|
|
|
const firstSplit = splitPoints[0];
|
|
const lastSplit = splitPoints[splitPoints.length - 1];
|
|
|
|
function resolveSplitNode(sp) {
|
|
const i = sp.segIdx;
|
|
if (Math.hypot(sp.coord[0] - wayCoords[i][0], sp.coord[1] - wayCoords[i][1]) <= SNAP_TOL)
|
|
return { atIndex: i, nodeId: osmNodes[i], insertAfter: null };
|
|
if (Math.hypot(sp.coord[0] - wayCoords[i + 1][0], sp.coord[1] - wayCoords[i + 1][1]) <= SNAP_TOL)
|
|
return { atIndex: i + 1, nodeId: osmNodes[i + 1], insertAfter: null };
|
|
return { atIndex: i + 1, nodeId: null, coord: sp.coord, insertAfter: i };
|
|
}
|
|
|
|
const firstResolved = resolveSplitNode(firstSplit);
|
|
const lastResolved = resolveSplitNode(lastSplit);
|
|
|
|
const midStart = firstResolved.atIndex + (firstResolved.nodeId !== null ? 1 : 0);
|
|
const midEnd = lastResolved.atIndex;
|
|
const middleNodeIds = osmNodes.slice(midStart, midEnd).filter(nid => (nodeRefCount.get(nid) || 0) <= 1);
|
|
const keptJunctionIds = osmNodes.slice(midStart, midEnd).filter(nid => (nodeRefCount.get(nid) || 0) > 1);
|
|
|
|
return {
|
|
type: 'split', osmId, osmVersion, osmNodes, wayCoords, tags,
|
|
firstResolved, lastResolved,
|
|
middleNodeIds, keptJunctionIds, splitPoints,
|
|
description: `Split way${osmId ? ' ' + osmId : ''} around "${firstSplit.newRoadName}"`
|
|
};
|
|
}
|
|
|
|
function showAutoFixModal(plans) {
|
|
return new Promise(resolve => {
|
|
const modal = document.getElementById('autoFixModal');
|
|
const body = document.getElementById('autoFixModalBody');
|
|
body.innerHTML = '';
|
|
|
|
plans.forEach((plan, i) => {
|
|
const div = document.createElement('div');
|
|
div.style.cssText = 'padding:10px;background:#f8f9fa;border-radius:4px;margin-bottom:8px;';
|
|
const lines = [`<strong>${plan.description}</strong>`];
|
|
if (plan.type === 'split') {
|
|
lines.push(`✓ Keep before-segment: nodes 0→${plan.firstResolved.atIndex}`);
|
|
if (plan.firstResolved.nodeId === null) lines.push('✓ Insert new node at split point');
|
|
lines.push(`✓ Keep after-segment: nodes ${plan.lastResolved.atIndex}→end`);
|
|
lines.push(`✓ Delete ${plan.middleNodeIds.length} single-use middle node(s)`);
|
|
if (plan.keptJunctionIds.length > 0)
|
|
lines.push(`⚠ Keep ${plan.keptJunctionIds.length} junction node(s) shared with other ways`);
|
|
} else {
|
|
const cnt = plan.deleteNodeIds ? plan.deleteNodeIds.length : 0;
|
|
lines.push(`✓ Delete way and ${cnt} single-use node(s)`);
|
|
}
|
|
div.innerHTML = `<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;">
|
|
<input type="checkbox" data-plan-idx="${i}" checked style="margin-top:2px;">
|
|
<div style="font-size:12px;">${lines.join('<br>')}</div>
|
|
</label>`;
|
|
body.appendChild(div);
|
|
});
|
|
|
|
modal.style.display = 'flex';
|
|
|
|
document.getElementById('autoFixCancel').onclick = () => { modal.style.display = 'none'; resolve(null); };
|
|
document.getElementById('autoFixExport').onclick = () => {
|
|
const checked = new Set();
|
|
body.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
|
if (cb.checked) checked.add(parseInt(cb.dataset.planIdx));
|
|
});
|
|
modal.style.display = 'none';
|
|
resolve(checked);
|
|
};
|
|
});
|
|
}
|
|
|
|
// ---- JOSM Export: XML generation ----
|
|
|
|
function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices, elementEdits = []) {
|
|
const nodeRegistry = new Map(); // "lon7,lat7" → id
|
|
const referencedExisting = new Map(); // nodeId (positive) → {coord, version}
|
|
let nextNewId = -1;
|
|
|
|
function allocNodeId(coord, existingNodeId) {
|
|
const key = coord[0].toFixed(7) + ',' + coord[1].toFixed(7);
|
|
if (nodeRegistry.has(key)) return nodeRegistry.get(key);
|
|
const id = existingNodeId ? existingNodeId : nextNewId--;
|
|
nodeRegistry.set(key, id);
|
|
if (existingNodeId) {
|
|
const version = osmNodeVersionById.get(existingNodeId) || 1;
|
|
referencedExisting.set(existingNodeId, { coord, version });
|
|
}
|
|
return id;
|
|
}
|
|
|
|
// Tracks nodes that need to be inserted into existing ways: feat → [{segIdx, t, nodeId, coord}]
|
|
const wayInsertions = new Map();
|
|
|
|
// Allocate an endpoint node, falling back to segment snap if no vertex snap exists.
|
|
function allocEndpointId(rawCoord, snap) {
|
|
if (snap) return allocNodeId(snap.coord, snap.nodeId);
|
|
// Try snapping to the nearest point on any existing way segment.
|
|
const segHit = findNearestOsmWayPoint(rawCoord, SNAP_TOL * 3);
|
|
if (!segHit) return allocNodeId(rawCoord, null);
|
|
// If the projection landed right on an existing node, use it.
|
|
const vertexHit = findNearestOsmVertex(segHit.coord, SNAP_TOL);
|
|
if (vertexHit) return allocNodeId(vertexHit.coord, vertexHit.nodeId);
|
|
// Midpoint of an existing way — allocate a new node and record the insertion.
|
|
const key = segHit.coord[0].toFixed(7) + ',' + segHit.coord[1].toFixed(7);
|
|
if (!nodeRegistry.has(key)) {
|
|
nodeRegistry.set(key, nextNewId--);
|
|
const list = wayInsertions.get(segHit.feat) || [];
|
|
list.push({ segIdx: segHit.segIdx, t: segHit.t, nodeId: nodeRegistry.get(key), coord: segHit.coord });
|
|
wayInsertions.set(segHit.feat, list);
|
|
}
|
|
return nodeRegistry.get(key);
|
|
}
|
|
|
|
const newWays = [];
|
|
const newNodes = []; // standalone point features (e.g. addresses), not way vertices
|
|
const modifiedWays = [];
|
|
const splitTailWays = [];
|
|
const deleteWayIds = []; // [{id, version}]
|
|
const deleteNodeIds = new Map(); // id -> version
|
|
|
|
acceptedAdded.forEach(feat => {
|
|
const tags = osmTagsFrom(feat.properties);
|
|
|
|
if (feat.geometry.type === 'Point') {
|
|
newNodes.push({ id: nextNewId--, coord: feat.geometry.coordinates, tags });
|
|
return;
|
|
}
|
|
|
|
const rawCoords = feat.geometry.coordinates;
|
|
const midCoords = (feat._snappedCoords || rawCoords).slice(1, -1);
|
|
const startId = allocEndpointId(rawCoords[0], feat._snapAEnabled !== false ? feat._snapA : null);
|
|
const endId = allocEndpointId(rawCoords[rawCoords.length - 1], feat._snapBEnabled !== false ? feat._snapB : null);
|
|
const ndRefs = [startId, ...midCoords.map(c => allocNodeId(c, null)), endId];
|
|
newWays.push({ id: nextNewId--, ndRefs, tags });
|
|
});
|
|
|
|
// Emit modify actions for existing ways that need a node inserted at a new intersection.
|
|
wayInsertions.forEach((insertions, feat) => {
|
|
const osmId = feat.properties.osm_id;
|
|
if (!osmId) return;
|
|
const osmVersion = feat.properties.osm_version || 1;
|
|
const osmNodes = feat.properties.osm_nodes || [];
|
|
const wayCoords = feat.geometry.coordinates;
|
|
insertions.sort((a, b) => a.segIdx !== b.segIdx ? a.segIdx - b.segIdx : a.t - b.t);
|
|
|
|
const ndRefs = [];
|
|
let insIdx = 0;
|
|
for (let i = 0; i < wayCoords.length; i++) {
|
|
ndRefs.push(allocNodeId(wayCoords[i], osmNodes[i] || null));
|
|
while (insIdx < insertions.length && insertions[insIdx].segIdx === i) {
|
|
ndRefs.push(insertions[insIdx].nodeId);
|
|
insIdx++;
|
|
}
|
|
}
|
|
const tags = osmTagsFrom(feat.properties);
|
|
modifiedWays.push({ id: osmId, version: osmVersion, ndRefs, tags });
|
|
});
|
|
|
|
removalPlans.forEach((plan, i) => {
|
|
if (!checkedPlanIndices.has(i) || !plan.osmId) return;
|
|
|
|
if (plan.type === 'deleteNode') {
|
|
deleteNodeIds.set(plan.osmId, plan.osmVersion || 1);
|
|
} else if (plan.type === 'stripAddress') {
|
|
elementEdits.push(plan.edit);
|
|
} else if (plan.type === 'delete') {
|
|
deleteWayIds.push({ id: plan.osmId, version: plan.osmVersion || 1 });
|
|
if (plan.deleteNodeIds) plan.deleteNodeIds.forEach(nid => deleteNodeIds.set(nid, osmNodeVersionById.get(nid) || 1));
|
|
} else if (plan.type === 'split') {
|
|
const beforeEnd = plan.firstResolved;
|
|
const beforeRefs = [];
|
|
for (let j = 0; j <= beforeEnd.atIndex; j++) {
|
|
beforeRefs.push(allocNodeId(plan.wayCoords[j], plan.osmNodes[j]));
|
|
}
|
|
if (beforeEnd.nodeId === null && beforeEnd.coord) {
|
|
beforeRefs.push(allocNodeId(beforeEnd.coord, null));
|
|
}
|
|
|
|
const afterStart = plan.lastResolved;
|
|
const afterRefs = [];
|
|
if (afterStart.nodeId === null && afterStart.coord) {
|
|
afterRefs.push(allocNodeId(afterStart.coord, null));
|
|
}
|
|
for (let j = afterStart.atIndex; j < plan.wayCoords.length; j++) {
|
|
afterRefs.push(allocNodeId(plan.wayCoords[j], plan.osmNodes[j]));
|
|
}
|
|
|
|
modifiedWays.push({ id: plan.osmId, version: plan.osmVersion || 1, ndRefs: beforeRefs, tags: plan.tags });
|
|
if (afterRefs.length > 1) {
|
|
splitTailWays.push({ id: nextNewId--, ndRefs: afterRefs, tags: plan.tags });
|
|
}
|
|
plan.middleNodeIds.forEach(nid => deleteNodeIds.set(nid, osmNodeVersionById.get(nid) || 1));
|
|
}
|
|
});
|
|
|
|
function esc(s) {
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function wayXml(w, action) {
|
|
let x = ` <way id="${w.id}"${action ? ` action="${action}"` : ''}${action === 'modify' ? ` version="${w.version || 1}"` : ''}>\n`;
|
|
w.ndRefs.forEach(ref => { x += ` <nd ref="${ref}"/>\n`; });
|
|
Object.entries(w.tags).forEach(([k, v]) => { x += ` <tag k="${esc(k)}" v="${esc(v)}"/>\n`; });
|
|
return x + ' </way>\n';
|
|
}
|
|
|
|
// Existing element with changed tags only. A way/relation lists its node
|
|
// refs/members unchanged; the referenced elements themselves aren't needed.
|
|
function elementEditXml(e) {
|
|
const tagXml = Object.entries(e.tags).map(([k, v]) => ` <tag k="${esc(k)}" v="${esc(v)}"/>\n`).join('');
|
|
if (e.type === 'node') {
|
|
return ` <node id="${e.id}" action="modify" version="${e.version}" lat="${e.coord[1].toFixed(7)}" lon="${e.coord[0].toFixed(7)}">\n${tagXml} </node>\n`;
|
|
}
|
|
if (e.type === 'way') {
|
|
const nds = (e.nodes || []).map(ref => ` <nd ref="${ref}"/>\n`).join('');
|
|
return ` <way id="${e.id}" action="modify" version="${e.version}">\n${nds}${tagXml} </way>\n`;
|
|
}
|
|
const members = (e.members || []).map(m => ` <member type="${esc(m.type)}" ref="${m.ref}" role="${esc(m.role || '')}"/>\n`).join('');
|
|
return ` <relation id="${e.id}" action="modify" version="${e.version}">\n${members}${tagXml} </relation>\n`;
|
|
}
|
|
|
|
function nodeXml(n) {
|
|
let x = ` <node id="${n.id}" action="create" lat="${n.coord[1].toFixed(7)}" lon="${n.coord[0].toFixed(7)}"`;
|
|
const tagEntries = Object.entries(n.tags);
|
|
if (tagEntries.length === 0) return x + '/>\n';
|
|
x += '>\n';
|
|
tagEntries.forEach(([k, v]) => { x += ` <tag k="${esc(k)}" v="${esc(v)}"/>\n`; });
|
|
return x + ' </node>\n';
|
|
}
|
|
|
|
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n<osm version="0.6" generator="osm-import-tools">\n\n';
|
|
|
|
referencedExisting.forEach(({ coord, version }, nid) => {
|
|
xml += ` <node id="${nid}" version="${version}" lat="${coord[1].toFixed(7)}" lon="${coord[0].toFixed(7)}"/>\n`;
|
|
});
|
|
nodeRegistry.forEach((id, key) => {
|
|
if (id >= 0) return;
|
|
const [lon, lat] = key.split(',').map(Number);
|
|
xml += ` <node id="${id}" action="create" lat="${lat.toFixed(7)}" lon="${lon.toFixed(7)}"/>\n`;
|
|
});
|
|
newNodes.forEach(n => { xml += nodeXml(n); });
|
|
if (referencedExisting.size > 0 || [...nodeRegistry.values()].some(id => id < 0) || newNodes.length > 0) xml += '\n';
|
|
|
|
newWays.forEach(w => { xml += wayXml(w, 'create'); });
|
|
modifiedWays.forEach(w => { xml += wayXml(w, 'modify'); });
|
|
elementEdits
|
|
.filter(e => e.id && (e.type === 'node' || (e.type === 'way' && e.nodes && e.nodes.length) || (e.type === 'relation' && e.members)))
|
|
.forEach(e => { xml += elementEditXml(e); });
|
|
splitTailWays.forEach(w => { xml += wayXml(w, 'create'); });
|
|
deleteWayIds.forEach(w => { xml += ` <way id="${w.id}" action="delete" version="${w.version}"/>\n`; });
|
|
deleteNodeIds.forEach((version, nid) => { xml += ` <node id="${nid}" action="delete" version="${version}"/>\n`; });
|
|
|
|
xml += '\n</osm>\n';
|
|
return xml;
|
|
}
|
|
|
|
async function exportToOsm() {
|
|
if (acceptedFeatures.size === 0) {
|
|
showStatus('No accepted features to export', 'error');
|
|
return;
|
|
}
|
|
|
|
const acceptedAdded = [];
|
|
const acceptedRemoved = [];
|
|
const renameEdits = [];
|
|
acceptedFeatures.forEach(feat => {
|
|
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
|
|
if (feat._acceptRename) {
|
|
// The OSM side carries the edit; the county side adds nothing.
|
|
if (isRemoved) {
|
|
const tags = osmTagsFrom(feat.properties);
|
|
tags['addr:street'] = String(feat.properties.possible_rename);
|
|
renameEdits.push(elementEdit(feat, tags));
|
|
}
|
|
return;
|
|
}
|
|
if (isRemoved) acceptedRemoved.push(feat);
|
|
else acceptedAdded.push(feat);
|
|
});
|
|
|
|
// Ensure all added features have snap info
|
|
acceptedAdded.forEach(feat => cacheSnapInfo(feat));
|
|
|
|
const nodeRefCount = buildNodeRefCount();
|
|
const removalPlans = acceptedRemoved.map(feat => computeRemovalPlan(feat, acceptedAdded, nodeRefCount));
|
|
|
|
const complexPlans = removalPlans.filter(p => p.type === 'split');
|
|
let checkedPlanIndices;
|
|
|
|
if (complexPlans.length > 0) {
|
|
const complexToFull = [];
|
|
removalPlans.forEach((p, i) => { if (p.type === 'split') complexToFull.push(i); });
|
|
const result = await showAutoFixModal(complexPlans);
|
|
if (result === null) return;
|
|
checkedPlanIndices = new Set(
|
|
[...removalPlans.keys()].filter(i => {
|
|
if (removalPlans[i].type !== 'split') return true;
|
|
return result.has(complexToFull.indexOf(i));
|
|
})
|
|
);
|
|
} else {
|
|
checkedPlanIndices = new Set(removalPlans.keys());
|
|
}
|
|
|
|
const xml = generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices, renameEdits);
|
|
const dataDate = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
const filename = `${loadedCounty || 'osm'}-${loadedDataType || 'export'}-${dataDate}.osm`;
|
|
|
|
const blob = new Blob([xml], { type: 'application/xml' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
showStatus(`Exported ${acceptedAdded.length} added, ${acceptedRemoved.length} removed${renameEdits.length ? `, ${renameEdits.length} renamed` : ''} as JOSM .osm`, 'success');
|
|
}
|
|
|
|
window.exportToOsm = exportToOsm;
|
|
|
|
// Update save button state
|
|
function updateSaveButton() {
|
|
document.getElementById('saveButton').disabled =
|
|
acceptedFeatures.size === 0 && rejectedFeatures.size === 0;
|
|
document.getElementById('exportOsmButton').disabled = acceptedFeatures.size === 0;
|
|
}
|
|
|
|
// Load GeoJSON from server
|
|
async function loadFromServer(url) {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
if (response.status === 404) {
|
|
return null; // File doesn't exist
|
|
}
|
|
throw new Error(`Failed to load ${url}: ${response.statusText}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
// Load all files from server
|
|
async function loadFiles() {
|
|
try {
|
|
showStatus('Loading data from server...', 'success');
|
|
|
|
const county = document.getElementById('countySelect').value;
|
|
const dataType = document.getElementById('dataTypeSelect').value;
|
|
loadedCounty = county;
|
|
loadedDataType = dataType;
|
|
|
|
// Load exclusions
|
|
try {
|
|
const exclResp = await fetch('/api/exclusions');
|
|
if (exclResp.ok) {
|
|
const exclData = await exclResp.json();
|
|
exclusions = exclData.exclusions || [];
|
|
}
|
|
} catch (_) {}
|
|
|
|
// Build file paths based on county and data type
|
|
let osmFile, diffFile, countyFile, diffAddedFile, diffRemovedFile;
|
|
|
|
if (dataType === 'roads') {
|
|
osmFile = `${county}/osm-roads.geojson`;
|
|
diffFile = `${county}/diff-roads.geojson`;
|
|
countyFile = `${county}/county-roads.geojson`;
|
|
} else if (dataType === 'paths') {
|
|
osmFile = `${county}/osm-paths.geojson`;
|
|
diffFile = `${county}/diff-paths.geojson`;
|
|
countyFile = `${county}/county-paths.geojson`;
|
|
} else if (dataType === 'addresses') {
|
|
osmFile = `${county}/osm-addresses.geojson`;
|
|
diffAddedFile = `${county}/addresses-to-add.geojson`;
|
|
diffRemovedFile = `${county}/addresses-potentially-removed.geojson`;
|
|
countyFile = `${county}/county-addresses.geojson`;
|
|
}
|
|
|
|
// Load files from server
|
|
osmData = osmFile ? await loadFromServer(`/data/${osmFile}`) : null;
|
|
countyData = countyFile ? await loadFromServer(`/data/${countyFile}`) : null;
|
|
|
|
// For addresses, load both added and removed files and combine them
|
|
if (dataType === 'addresses' && (diffAddedFile || diffRemovedFile)) {
|
|
const addedData = diffAddedFile ? await loadFromServer(`/data/${diffAddedFile}`) : null;
|
|
const removedData = diffRemovedFile ? await loadFromServer(`/data/${diffRemovedFile}`) : null;
|
|
|
|
// Combine both datasets into diffData
|
|
diffData = {
|
|
type: 'FeatureCollection',
|
|
features: []
|
|
};
|
|
|
|
// Add "removed" features FIRST (red) - they will render beneath
|
|
if (removedData && removedData.features) {
|
|
removedData.features.forEach(feature => {
|
|
if (!feature.properties) feature.properties = {};
|
|
feature.properties.removed = true;
|
|
diffData.features.push(feature);
|
|
});
|
|
}
|
|
|
|
// Add "added" features SECOND (green) - they will render on top
|
|
if (addedData && addedData.features) {
|
|
addedData.features.forEach(feature => {
|
|
// Ensure removed property is not set or is false
|
|
if (!feature.properties) feature.properties = {};
|
|
feature.properties.removed = false;
|
|
diffData.features.push(feature);
|
|
});
|
|
}
|
|
|
|
// If no features loaded, set to null
|
|
if (diffData.features.length === 0) {
|
|
diffData = null;
|
|
}
|
|
} else {
|
|
// For roads/paths, load single diff file
|
|
diffData = diffFile ? await loadFromServer(`/data/${diffFile}`) : null;
|
|
}
|
|
|
|
if (!osmData && !diffData && !countyData) {
|
|
showStatus(`No data files found for ${county} ${dataType}. Run the processing scripts first.`, 'error');
|
|
return;
|
|
}
|
|
|
|
// Create layers
|
|
createOsmLayer();
|
|
buildOsmNodeIndex();
|
|
markPossibleRenames();
|
|
createDiffLayer();
|
|
createCountyLayer();
|
|
|
|
// Fit bounds to smallest layer
|
|
calculateBounds();
|
|
|
|
let loadedFiles = [];
|
|
if (osmData) loadedFiles.push('OSM');
|
|
if (diffData) loadedFiles.push('Diff');
|
|
if (countyData) loadedFiles.push('County');
|
|
|
|
showStatus(`Loaded ${loadedFiles.join(', ')} data for ${county} ${dataType}`, 'success');
|
|
|
|
// Enable save button if we have diff data
|
|
document.getElementById('saveButton').disabled = !diffData;
|
|
|
|
} catch (error) {
|
|
showStatus(error.message, 'error');
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
// Save accepted items to separate files (added-approved.geojson and removed-approved.geojson)
|
|
async function saveAcceptedItems() {
|
|
if (!diffData || acceptedFeatures.size === 0) {
|
|
showStatus('No accepted features to save', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Separate accepted features into added and removed
|
|
const acceptedAdded = [];
|
|
const acceptedRemoved = [];
|
|
|
|
diffData.features.forEach(feature => {
|
|
if (acceptedFeatures.has(feature)) {
|
|
// Clone feature and remove status/approved/removed properties
|
|
const cleanFeature = {
|
|
type: feature.type,
|
|
geometry: feature.geometry,
|
|
properties: {}
|
|
};
|
|
|
|
// Copy all properties except status, accepted, and removed
|
|
Object.keys(feature.properties).forEach(key => {
|
|
if (key !== 'status' && key !== 'accepted' && key !== 'removed') {
|
|
cleanFeature.properties[key] = feature.properties[key];
|
|
}
|
|
});
|
|
|
|
// Determine if added or removed
|
|
const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True');
|
|
if (isRemoved) {
|
|
acceptedRemoved.push(cleanFeature);
|
|
} else {
|
|
acceptedAdded.push(cleanFeature);
|
|
}
|
|
}
|
|
});
|
|
|
|
const dataDate = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
const prefix = [loadedCounty, loadedDataType, dataDate].filter(Boolean).join('-');
|
|
|
|
function triggerDownload(features, label) {
|
|
const blob = new Blob([JSON.stringify({ type: 'FeatureCollection', features }, null, 2)],
|
|
{ type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${prefix}-${label}.geojson`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
if (acceptedAdded.length > 0) triggerDownload(acceptedAdded, 'added-approved');
|
|
if (acceptedRemoved.length > 0) triggerDownload(acceptedRemoved, 'removed-approved');
|
|
|
|
showStatus(`Saved ${acceptedAdded.length} added, ${acceptedRemoved.length} removed (approved only)`, 'success');
|
|
|
|
} catch (error) {
|
|
showStatus(`Save failed: ${error.message}`, 'error');
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
// Show status message
|
|
function showStatus(message, type) {
|
|
const status = document.getElementById('status');
|
|
status.textContent = message;
|
|
status.className = `status ${type}`;
|
|
|
|
setTimeout(() => {
|
|
status.classList.add('hidden');
|
|
}, 3000);
|
|
}
|
|
|
|
// Update pane z-index based on order
|
|
function updateLayerZIndex() {
|
|
const panes = {
|
|
'osm': 'osmPane',
|
|
'diff': 'diffPane',
|
|
'county': 'countyPane'
|
|
};
|
|
|
|
const layers = {
|
|
'osm': osmLayer,
|
|
'diff': diffLayer,
|
|
'county': countyLayer
|
|
};
|
|
|
|
// Reverse index so first item in list is on top
|
|
// Use higher base z-index (650) to ensure we're above overlay pane
|
|
layerOrder.forEach((layerName, index) => {
|
|
const paneName = panes[layerName];
|
|
const pane = map.getPane(paneName);
|
|
if (pane) {
|
|
const zIndex = 650 + (layerOrder.length - 1 - index);
|
|
pane.style.zIndex = zIndex;
|
|
}
|
|
});
|
|
|
|
// Also bring layers to front in reverse order (last first, so first ends up on top)
|
|
for (let i = layerOrder.length - 1; i >= 0; i--) {
|
|
const layer = layers[layerOrder[i]];
|
|
if (layer && map.hasLayer(layer)) {
|
|
layer.bringToFront();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Toggle layer visibility
|
|
function toggleLayer(layerId, layer) {
|
|
const checkbox = document.getElementById(layerId);
|
|
|
|
if (checkbox.checked && layer) {
|
|
if (!map.hasLayer(layer)) {
|
|
map.addLayer(layer);
|
|
}
|
|
} else if (layer) {
|
|
if (map.hasLayer(layer)) {
|
|
map.removeLayer(layer);
|
|
}
|
|
}
|
|
|
|
// Always update z-index after toggling any 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();
|
|
|
|
// Mobile hamburger menu toggle (shared helper in map-ui.js)
|
|
initMobileControls();
|
|
|
|
// Layer toggles
|
|
document.getElementById('osmToggle').addEventListener('change', function() {
|
|
toggleLayer('osmToggle', osmLayer);
|
|
});
|
|
|
|
document.getElementById('diffToggle').addEventListener('change', function() {
|
|
toggleLayer('diffToggle', diffLayer);
|
|
});
|
|
|
|
document.getElementById('countyToggle').addEventListener('change', function() {
|
|
toggleLayer('countyToggle', countyLayer);
|
|
});
|
|
|
|
// Diff filter toggles
|
|
document.getElementById('showAdded').addEventListener('change', function() {
|
|
createDiffLayer();
|
|
});
|
|
|
|
document.getElementById('showConflicted').addEventListener('change', function() {
|
|
createDiffLayer();
|
|
});
|
|
|
|
document.getElementById('showRemoved').addEventListener('change', function() {
|
|
createDiffLayer();
|
|
});
|
|
|
|
document.getElementById('hideService').addEventListener('change', function() {
|
|
createDiffLayer();
|
|
createOsmLayer();
|
|
createCountyLayer();
|
|
});
|
|
|
|
document.getElementById('hideUnclassified').addEventListener('change', function() {
|
|
createDiffLayer();
|
|
createOsmLayer();
|
|
createCountyLayer();
|
|
});
|
|
|
|
document.getElementById('showSnapPreview').addEventListener('change', function() {
|
|
updateSnapPreview();
|
|
});
|
|
|
|
document.getElementById('snapDistance').addEventListener('change', function() {
|
|
const meters = Math.max(1, parseFloat(this.value) || 10);
|
|
this.value = meters;
|
|
SNAP_TOL = meters / 111320;
|
|
acceptedFeatures.forEach(feat => {
|
|
if (feat.geometry && feat.geometry.type === 'LineString') cacheSnapInfo(feat);
|
|
});
|
|
updateSnapPreview();
|
|
if (popupLatlng && selectedFeatures.length > 0) showMultiFeaturePopup(popupLatlng);
|
|
});
|
|
|
|
// Click on empty map area closes any open popup and clears selection
|
|
map.on('click', function(e) {
|
|
if (multiSelectMode) return;
|
|
if (e.originalEvent && e.originalEvent.shiftKey) return;
|
|
clearSelection();
|
|
});
|
|
|
|
// Load button
|
|
document.getElementById('loadButton').addEventListener('click', function() {
|
|
loadFiles();
|
|
// Auto-close controls on mobile after clicking load
|
|
if (window.innerWidth <= 768) {
|
|
setTimeout(window.closeMobileControls, 300);
|
|
}
|
|
});
|
|
|
|
// Save button
|
|
document.getElementById('saveButton').addEventListener('click', saveAcceptedItems);
|
|
|
|
// Export to JOSM button
|
|
document.getElementById('exportOsmButton').addEventListener('click', exportToOsm);
|
|
|
|
// 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() {
|
|
setMultiSelectMode(!multiSelectMode);
|
|
});
|
|
document.getElementById('multiSelectFab').addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
setMultiSelectMode(!multiSelectMode);
|
|
});
|
|
|
|
// Drag and drop for layer reordering
|
|
const layerList = document.getElementById('layerList');
|
|
const layerItems = layerList.querySelectorAll('.layer-item');
|
|
|
|
let draggedElement = null;
|
|
|
|
layerItems.forEach(item => {
|
|
item.addEventListener('dragstart', function(e) {
|
|
draggedElement = this;
|
|
this.classList.add('dragging');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
|
|
item.addEventListener('dragend', function(e) {
|
|
this.classList.remove('dragging');
|
|
draggedElement = null;
|
|
});
|
|
|
|
item.addEventListener('dragover', function(e) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
|
|
if (this === draggedElement) return;
|
|
|
|
const afterElement = getDragAfterElement(layerList, e.clientY);
|
|
if (afterElement == null) {
|
|
layerList.appendChild(draggedElement);
|
|
} else {
|
|
layerList.insertBefore(draggedElement, afterElement);
|
|
}
|
|
});
|
|
|
|
item.addEventListener('drop', function(e) {
|
|
e.preventDefault();
|
|
|
|
// Update layer order based on new DOM order
|
|
layerOrder = Array.from(layerList.querySelectorAll('.layer-item'))
|
|
.map(item => item.dataset.layer);
|
|
|
|
updateLayerZIndex();
|
|
});
|
|
});
|
|
|
|
function getDragAfterElement(container, y) {
|
|
const draggableElements = [...container.querySelectorAll('.layer-item:not(.dragging)')];
|
|
|
|
return draggableElements.reduce((closest, child) => {
|
|
const box = child.getBoundingClientRect();
|
|
const offset = y - box.top - box.height / 2;
|
|
|
|
if (offset < 0 && offset > closest.offset) {
|
|
return { offset: offset, element: child };
|
|
} else {
|
|
return closest;
|
|
}
|
|
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
|
}
|
|
});
|