Address rename detection with Accept Rename tag-edit export, hover-to-toggle snap dots, cross-layer click fix, fix county street naming (Saint, County Road, Parkway), and keep OSM ids in address downloads
This commit is contained in:
+342
-77
@@ -55,7 +55,24 @@ function distanceToLayer(layer, p) {
|
||||
// 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) {
|
||||
@@ -72,14 +89,14 @@ const NearestHitCanvas = L.Canvas.extend({
|
||||
},
|
||||
|
||||
_onClick: function(e) {
|
||||
const clickedLayer = this._nearestLayer(this._map.mouseEventToLayerPoint(e), 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._nearestLayer(point);
|
||||
const candidateHoveredLayer = this._hitTest(point);
|
||||
|
||||
if (candidateHoveredLayer !== this._hoveredLayer) {
|
||||
this._handleMouseOut(e);
|
||||
@@ -124,11 +141,18 @@ let SNAP_TOL = 10 / 111320; // default 10m in degrees; updated from UI
|
||||
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');
|
||||
@@ -414,20 +438,33 @@ function diffStyle(feature) {
|
||||
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' : '#00ff00'),
|
||||
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: '#007bff',
|
||||
color: '#0056b3',
|
||||
fillColor: rename ? '#0b3d91' : '#007bff',
|
||||
color: rename ? '#061f4a' : '#0056b3',
|
||||
weight: 1,
|
||||
opacity: 0.8,
|
||||
fillOpacity: 0.7
|
||||
@@ -448,10 +485,17 @@ function diffMarkerStyle(feature) {
|
||||
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: isRemoved ? '#ff0000' : (isConflicted ? '#ffeb3b' : '#00ff00'),
|
||||
color: isRemoved ? '#cc0000' : (isConflicted ? '#c9a800' : '#00cc00'),
|
||||
fillColor: fill,
|
||||
color: stroke,
|
||||
weight: 1,
|
||||
opacity: 0.8,
|
||||
fillOpacity: 0.7
|
||||
@@ -610,6 +654,7 @@ function createDiffLayer() {
|
||||
|
||||
layer.on('mouseover', function(e) {
|
||||
const isSelected = selectedLayers.some(l => l.layer === layer);
|
||||
if (!isPoint) showHoverSnapDots(feature);
|
||||
if (!isSelected) {
|
||||
if (isPoint) {
|
||||
layer.setStyle({
|
||||
@@ -627,6 +672,7 @@ function createDiffLayer() {
|
||||
|
||||
layer.on('mouseout', function(e) {
|
||||
const isSelected = selectedLayers.some(l => l.layer === layer);
|
||||
if (!isPoint) scheduleHideHoverSnap();
|
||||
if (!isSelected) {
|
||||
if (isPoint) {
|
||||
layer.setStyle(diffMarkerStyle(feature));
|
||||
@@ -924,6 +970,11 @@ function showMultiFeaturePopup(latlng) {
|
||||
(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]);
|
||||
}
|
||||
@@ -936,16 +987,14 @@ function showMultiFeaturePopup(latlng) {
|
||||
html += `<div style="margin-top: 8px;"><strong>Status:</strong> All Removed (Red)</div>`;
|
||||
}
|
||||
|
||||
// Per-feature connectivity / snap toggles for multiselect
|
||||
// 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;
|
||||
const name = (feat.properties && feat.properties.name) || `Road ${i + 1}`;
|
||||
html += `<div style="margin-top:6px;padding-top:4px;border-top:1px solid #eee;font-size:11px;font-weight:bold;">${name}</div>`;
|
||||
html += connectivitySection(feat, i);
|
||||
html += connectivitySection(feat, i, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -957,7 +1006,8 @@ function showMultiFeaturePopup(latlng) {
|
||||
|
||||
if (acceptedCount > 0 || rejectedCount > 0) {
|
||||
html += '<div style="margin-top: 8px; font-size: 11px;">';
|
||||
if (acceptedCount > 0) html += `<div style="color: #007bff;">✓ ${acceptedCount} accepted</div>`;
|
||||
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>';
|
||||
}
|
||||
@@ -968,6 +1018,11 @@ function showMultiFeaturePopup(latlng) {
|
||||
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;">';
|
||||
@@ -1019,9 +1074,11 @@ function showMultiFeaturePopup(latlng) {
|
||||
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);
|
||||
@@ -1042,6 +1099,8 @@ function acceptAllFeatures() {
|
||||
}
|
||||
});
|
||||
|
||||
restyleDiffFeatures(unpaired);
|
||||
|
||||
// Close popup
|
||||
if (featurePopup) {
|
||||
map.closePopup(featurePopup);
|
||||
@@ -1060,9 +1119,11 @@ function acceptAllFeatures() {
|
||||
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);
|
||||
@@ -1079,6 +1140,8 @@ function rejectAllFeatures() {
|
||||
}
|
||||
});
|
||||
|
||||
restyleDiffFeatures(unpaired);
|
||||
|
||||
// Close popup
|
||||
if (featurePopup) {
|
||||
map.closePopup(featurePopup);
|
||||
@@ -1161,7 +1224,73 @@ async function excludeSelectedFeatures() {
|
||||
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;
|
||||
@@ -1253,6 +1382,45 @@ function segIntersect(p1, p2, p3, p4) {
|
||||
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;
|
||||
@@ -1386,8 +1554,8 @@ function countyPropsToOsmTags(props) {
|
||||
// ---- Snap preview markers ----
|
||||
|
||||
// Draw snap preview for one feature onto snapPreviewLayer.
|
||||
// interactive=true also adds clickable endpoint dots (only used for the selected feature).
|
||||
function drawSnapPreviewForFeature(feature, interactive) {
|
||||
// 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;
|
||||
|
||||
@@ -1410,49 +1578,89 @@ function drawSnapPreviewForFeature(feature, interactive) {
|
||||
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(snapPreviewLayer);
|
||||
}).addTo(target);
|
||||
});
|
||||
|
||||
if (!interactive) return;
|
||||
|
||||
ends.forEach(({ isStart, snap, segSnap, enabledFlag, isEnabled, finalCoord }) => {
|
||||
let color, canToggle;
|
||||
if (snap) {
|
||||
color = isEnabled ? '#28a745' : '#dc3545'; canToggle = true;
|
||||
} else if (segSnap) {
|
||||
color = isEnabled ? '#007bff' : '#dc3545'; canToggle = true;
|
||||
} else {
|
||||
color = '#ffc107'; canToggle = false;
|
||||
}
|
||||
// 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 ? `Click to disable ${isStart ? 'start' : 'end'} snap` : 'Click to re-enable snap',
|
||||
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(`${isStart ? 'Start' : 'End'}: dangling`, { sticky: true });
|
||||
marker.bindTooltip(`${endLabel}: no nearby road to snap to`, { sticky: true });
|
||||
}
|
||||
snapPreviewLayer.addLayer(marker);
|
||||
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;
|
||||
@@ -1477,19 +1685,13 @@ function updateSnapPreview() {
|
||||
});
|
||||
}
|
||||
|
||||
window.toggleSnapA = function(idx) {
|
||||
idx = idx === undefined ? 0 : idx;
|
||||
if (idx >= selectedFeatures.length) return;
|
||||
selectedFeatures[idx]._snapAEnabled = selectedFeatures[idx]._snapAEnabled !== false ? false : true;
|
||||
updateSnapPreview();
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
};
|
||||
window.toggleSnapB = function(idx) {
|
||||
idx = idx === undefined ? 0 : idx;
|
||||
if (idx >= selectedFeatures.length) return;
|
||||
selectedFeatures[idx]._snapBEnabled = selectedFeatures[idx]._snapBEnabled !== false ? false : true;
|
||||
updateSnapPreview();
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
// 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() {
|
||||
@@ -1514,17 +1716,17 @@ window.importCountyFeature = function() {
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
};
|
||||
|
||||
function connectivitySection(feature, idx) {
|
||||
// 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;
|
||||
const snapA = feature._snapA;
|
||||
const snapB = feature._snapB;
|
||||
const segA = feature._segSnapA;
|
||||
const segB = feature._segSnapB;
|
||||
|
||||
// Check if an endpoint connects to another new diff road (shares coordinate within tolerance)
|
||||
function findDiffConnection(coord) {
|
||||
@@ -1541,46 +1743,59 @@ function connectivitySection(feature, idx) {
|
||||
return best;
|
||||
}
|
||||
|
||||
function endpointHtml(label, snap, segSnap, toggleFn) {
|
||||
const enabledFlag = toggleFn === 'toggleSnapA' ? '_snapAEnabled' : '_snapBEnabled';
|
||||
const isEnabled = feature[enabledFlag] !== false;
|
||||
const toggleBtn = (snap || segSnap)
|
||||
? ` <button onclick="${toggleFn}(${idx})" style="font-size:10px;padding:1px 5px;cursor:pointer;">${isEnabled ? 'Disable snap' : 'Enable snap'}</button>`
|
||||
: '';
|
||||
|
||||
if (snap) {
|
||||
const isEndpoint = snap.posInWay === 0 || snap.posInWay === snap.wayLen - 1;
|
||||
const m = Math.round(snap.dist * 111320);
|
||||
const roadName = snap.name || 'unnamed road';
|
||||
const typeStr = isEndpoint ? 'endpoint of' : 'midpoint of';
|
||||
const color = isEnabled ? '#155724' : '#6c757d';
|
||||
return `<div style="color:${color};">✓ ${label}: snaps to ${typeStr} "${roadName}" (${m} m)${toggleBtn}</div>`;
|
||||
// {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)` };
|
||||
}
|
||||
if (segSnap) {
|
||||
const m = Math.round(segSnap.dist * 111320);
|
||||
const roadName = segSnap.feat.properties && (segSnap.feat.properties.name || segSnap.feat.properties.highway) || 'unnamed road';
|
||||
const color = isEnabled ? '#155724' : '#6c757d';
|
||||
return `<div style="color:${color};">✓ ${label}: will insert node into "${roadName}" (${m} m) on export${toggleBtn}</div>`;
|
||||
}
|
||||
const diffConn = findDiffConnection(label === 'Start' ? coords[0] : coords[coords.length - 1]);
|
||||
const diffConn = findDiffConnection(isStart ? coords[0] : coords[coords.length - 1]);
|
||||
if (diffConn) {
|
||||
return `<div style="color:#155724;">✓ ${label}: connects to new "${diffConn}"</div>`;
|
||||
return { icon: '✓', color: '#155724', short: 'joins new', text: `connects to new "${diffConn}"` };
|
||||
}
|
||||
return `<div style="color:#856404;">⚠ ${label}: no nearby road — dangling</div>`;
|
||||
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>';
|
||||
html += endpointHtml('Start', snapA, segA, 'toggleSnapA');
|
||||
html += endpointHtml('End', snapB, segB, 'toggleSnapB');
|
||||
['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 sameName = newName && c.name && c.name.toLowerCase() === newName.toLowerCase();
|
||||
const hint = sameName ? ' — <em>possible rename/reroute</em>' : '';
|
||||
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;
|
||||
}
|
||||
@@ -1604,9 +1819,9 @@ 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_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions', 'osm_members',
|
||||
// compare-addresses.py annotations
|
||||
'conflict', 'county_city', 'county_postal_community',
|
||||
'conflict', 'county_city', 'county_postal_community', 'possible_rename', 'rename_pair',
|
||||
]);
|
||||
|
||||
// Turn feature properties into an OSM tag map, dropping internal keys and
|
||||
@@ -1619,6 +1834,16 @@ function osmTagsFrom(props) {
|
||||
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'];
|
||||
@@ -1627,6 +1852,15 @@ function computeRemovalPlan(removedFeat, acceptedAddedFeats, nodeRefCount) {
|
||||
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 : ''}` };
|
||||
}
|
||||
|
||||
@@ -1735,7 +1969,7 @@ function showAutoFixModal(plans) {
|
||||
|
||||
// ---- JOSM Export: XML generation ----
|
||||
|
||||
function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) {
|
||||
function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices, elementEdits = []) {
|
||||
const nodeRegistry = new Map(); // "lon7,lat7" → id
|
||||
const referencedExisting = new Map(); // nodeId (positive) → {coord, version}
|
||||
let nextNewId = -1;
|
||||
@@ -1825,6 +2059,8 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) {
|
||||
|
||||
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));
|
||||
@@ -1866,6 +2102,21 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) {
|
||||
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);
|
||||
@@ -1890,6 +2141,9 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) {
|
||||
|
||||
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`; });
|
||||
@@ -1906,8 +2160,18 @@ async function exportToOsm() {
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -1936,7 +2200,7 @@ async function exportToOsm() {
|
||||
checkedPlanIndices = new Set(removalPlans.keys());
|
||||
}
|
||||
|
||||
const xml = generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices);
|
||||
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`;
|
||||
|
||||
@@ -1950,7 +2214,7 @@ async function exportToOsm() {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showStatus(`Exported ${acceptedAdded.length} added, ${acceptedRemoved.length} removed as JOSM .osm`, 'success');
|
||||
showStatus(`Exported ${acceptedAdded.length} added, ${acceptedRemoved.length} removed${renameEdits.length ? `, ${renameEdits.length} renamed` : ''} as JOSM .osm`, 'success');
|
||||
}
|
||||
|
||||
window.exportToOsm = exportToOsm;
|
||||
@@ -2008,7 +2272,7 @@ async function loadFiles() {
|
||||
osmFile = `${county}/osm-addresses.geojson`;
|
||||
diffAddedFile = `${county}/addresses-to-add.geojson`;
|
||||
diffRemovedFile = `${county}/addresses-potentially-removed.geojson`;
|
||||
countyFile = `${county}/addresses.shp_converted.geojson`;
|
||||
countyFile = `${county}/county-addresses.geojson`;
|
||||
}
|
||||
|
||||
// Load files from server
|
||||
@@ -2062,6 +2326,7 @@ async function loadFiles() {
|
||||
// Create layers
|
||||
createOsmLayer();
|
||||
buildOsmNodeIndex();
|
||||
markPossibleRenames();
|
||||
createDiffLayer();
|
||||
createCountyLayer();
|
||||
|
||||
|
||||
@@ -130,6 +130,8 @@
|
||||
.leaflet-container.multi-select-active.leaflet-grab {
|
||||
cursor: crosshair !important;
|
||||
}
|
||||
/* One line per road in a multi-select popup; hover highlights it on the map */
|
||||
.conn-row:hover { background: #e0f7fa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -175,7 +177,7 @@
|
||||
<h3 style="margin-top: 10px;">Diff Filters</h3>
|
||||
<label>
|
||||
<input type="checkbox" id="showAdded" checked>
|
||||
Show Added (Green)
|
||||
Show Added (Green; darker = possible rename/reroute)
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="showConflicted" checked>
|
||||
@@ -183,7 +185,7 @@
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="showRemoved">
|
||||
Show Removed (Red)
|
||||
Show Removed (Red; darker = possible rename)
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="hideService">
|
||||
|
||||
Reference in New Issue
Block a user