diff --git a/diff-highways.py b/diff-highways.py
index bbd291a..0dd3231 100644
--- a/diff-highways.py
+++ b/diff-highways.py
@@ -13,7 +13,6 @@ TODO:
- ignore points outside of lines
- put properties properly on removed roads, so they're visible in JOSM
- handle polygons properly (on previous geojson step?) for circular roads
-- include OneWay=Y
- handle C 44a -> County Road 44A
- handle Trce -> Trace/Terrace?
"""
@@ -400,6 +399,11 @@ class RoadComparator:
elif key == 'StreetClas':
highway_type = qgisfunctions.gethighwaytype(value, None, None)
properties['highway'] = highway_type if highway_type else 'residential'
+ elif key == 'Oneway':
+ if value == 'FT':
+ properties['oneway'] = 'yes'
+ elif value == 'TF':
+ properties['oneway'] = '-1'
elif is_sumter_county:
# Sumter County field mappings
for key, value in original_properties.items():
@@ -417,6 +421,18 @@ class RoadComparator:
properties['highway'] = 'primary'
else:
properties['highway'] = 'residential'
+ elif key == 'OneWayCode':
+ if value == 'F':
+ properties['oneway'] = 'yes'
+ elif value == 'T':
+ properties['oneway'] = '-1'
+ elif key == 'LANES':
+ try:
+ num_value = int(float(value)) if value is not None else 0
+ if num_value > 0:
+ properties['lanes'] = str(num_value)
+ except (ValueError, TypeError):
+ pass
else:
# Unknown format - try common field names
name = original_properties.get('NAME') or original_properties.get('FullStreet') or original_properties.get('name')
diff --git a/docker-compose.yml b/docker-compose.yml
index 684c25c..709a8d5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,11 +4,16 @@ services:
web:
build:
context: .
- pull: true
ports:
- "5000:5000"
volumes:
- ./data:/data
+ - ./web:/app/web
+ - ./download-overpass.py:/app/download-overpass.py
+ - ./diff-highways.py:/app/diff-highways.py
+ - ./shp-to-geojson.py:/app/shp-to-geojson.py
+ - ./convert-addresses.py:/app/convert-addresses.py
+ - ./compare-addresses.py:/app/compare-addresses.py
env_file:
- stack.env
environment:
diff --git a/download-overpass.py b/download-overpass.py
index fd5e219..551b7e2 100644
--- a/download-overpass.py
+++ b/download-overpass.py
@@ -57,7 +57,7 @@ nwr["addr:housenumber"](area.searchArea);
out center;"""
return query
- base_query = f"""[out:json][timeout:120];
+ base_query = f"""[out:json][timeout:180];
area(id:{area_id})->.searchArea;"""
if data_type == "highways":
@@ -72,13 +72,13 @@ area(id:{area_id})->.searchArea;"""
selector += 'way["highway"~"_link"](area.searchArea);'
selector += 'way["highway"="service"](area.searchArea);'
selector += 'way["highway"="track"](area.searchArea);'
- selector += ');'
+ selector += ')->.ways;'
elif data_type == "multimodal":
- selector = '(way["highway"="path"](area.searchArea);way["highway"="cycleway"](area.searchArea););'
+ selector = '(way["highway"="path"](area.searchArea);way["highway"="cycleway"](area.searchArea);)->.ways;'
else:
raise ValueError(f"Unknown data type: {data_type}")
- query = base_query + selector + "out geom;"
+ query = base_query + selector + ".ways out meta geom;\nnode(w.ways);\nout meta;"
return query
@@ -117,10 +117,18 @@ def query_overpass(query):
def convert_to_geojson(overpass_data):
"""Convert Overpass API response to GeoJSON format."""
+ # First pass: collect node versions (present when query includes node metadata)
+ node_versions = {}
+ for element in overpass_data.get("elements", []):
+ if element["type"] == "node" and "version" in element:
+ node_versions[element["id"]] = element["version"]
+
features = []
for element in overpass_data.get("elements", []):
if element["type"] == "node":
+ if not element.get("tags"):
+ continue # Skip topology-only nodes (no tags); versions collected above
feature = {
"type": "Feature",
"properties": element.get("tags", {}),
@@ -133,11 +141,18 @@ def convert_to_geojson(overpass_data):
elif element["type"] in ("way", "relation"):
if "geometry" in element:
- # out geom; — full coordinate list (used for highways/paths)
+ # out meta geom; — full coordinate list + node IDs + version
coordinates = [[coord["lon"], coord["lat"]] for coord in element["geometry"]]
+ props = dict(element.get("tags", {}))
+ props["osm_id"] = element["id"]
+ props["osm_type"] = element["type"]
+ props["osm_version"] = element.get("version", 1)
+ if "nodes" in element:
+ props["osm_nodes"] = element["nodes"]
+ props["osm_node_versions"] = [node_versions.get(nid, 1) for nid in element["nodes"]]
feature = {
"type": "Feature",
- "properties": element.get("tags", {}),
+ "properties": props,
"geometry": {
"type": "LineString",
"coordinates": coordinates
diff --git a/web/static/map.js b/web/static/map.js
index a56e7dd..69d0558 100644
--- a/web/static/map.js
+++ b/web/static/map.js
@@ -27,6 +27,17 @@ let mobileControlsOpen = false;
let loadedCounty = '';
let loadedDataType = '';
+// OSM node index for JOSM export snapping
+let osmVertices = [];
+let osmNodeById = new Map();
+let osmCoordToNodeId = new Map();
+let osmNodeVersionById = new Map();
+const SNAP_TOL = 0.000135; // ~15m in degrees
+
+// Snap preview layer and popup state
+let snapPreviewLayer = null;
+let popupLatlng = null;
+
// Initialize map
function initMap() {
map = L.map('map').setView([28.7, -81.7], 12);
@@ -37,6 +48,8 @@ function initMap() {
maxZoom: 20
}).addTo(map);
+ snapPreviewLayer = L.layerGroup().addTo(map);
+
// Create custom panes for layer ordering
map.createPane('osmPane');
map.createPane('diffPane');
@@ -203,6 +216,7 @@ function clearSelection() {
selectedFeatures = [];
selectedLayers = [];
+ if (snapPreviewLayer) snapPreviewLayer.clearLayers();
if (featurePopup) {
map.closePopup(featurePopup);
@@ -685,12 +699,18 @@ function selectFeature(feature, layer, e, layerType = 'diff') {
// Show popup for single or multiple features
showMultiFeaturePopup(e.latlng);
+
+ if (layerType === 'diff' && feature.geometry.type === 'LineString') {
+ const isAdded = !feature.properties || (feature.properties.removed !== true && feature.properties.removed !== 'True');
+ if (isAdded && osmVertices.length > 0) updateSnapPreview(feature);
+ }
}
}
// Show popup for multiple features (JOSM-style aggregation)
function showMultiFeaturePopup(latlng) {
if (selectedFeatures.length === 0) return;
+ popupLatlng = latlng;
// Remove old popup if exists
if (featurePopup) {
@@ -787,6 +807,9 @@ function showMultiFeaturePopup(latlng) {
// Single selection - show specific status
const isRemoved = selectedFeatures[0].properties && (selectedFeatures[0].properties.removed === true || selectedFeatures[0].properties.removed === 'True');
html += `
Status: ${isRemoved ? 'Removed (Red)' : 'Added (Green)'}
`;
+ if (!isRemoved && osmVertices.length > 0) {
+ html += connectivitySection(selectedFeatures[0]);
+ }
} else if (addedCount > 0 && removedCount > 0) {
// Mixed selection
html += `Status: ${addedCount} added, ${removedCount} removed
`;
@@ -823,6 +846,26 @@ function showMultiFeaturePopup(latlng) {
}
}
+ // 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 += '';
+ html += '
Import as:';
+ Object.entries(osmTags).forEach(([k, v]) => {
+ html += `
${k}: ${v}
`;
+ });
+ if (alreadyImported) {
+ html += '
✓ Already imported
';
+ } else {
+ html += `
`;
+ }
+ html += '
';
+ }
+
html += '';
// Create popup at click location
@@ -853,6 +896,10 @@ function acceptAllFeatures() {
// 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) {
@@ -989,10 +1036,705 @@ 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];
+}
+
+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._snappedCoords = [
+ feature._snapA ? feature._snapA.coord : coords[0],
+ ...coords.slice(1, -1),
+ feature._snapB ? feature._snapB.coord : coords[coords.length - 1]
+ ];
+}
+
+// ---- 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 ----
+
+function updateSnapPreview(feature) {
+ if (!snapPreviewLayer) return;
+ snapPreviewLayer.clearLayers();
+ if (!feature || !feature.geometry || feature.geometry.type !== 'LineString') return;
+
+ cacheSnapInfo(feature);
+ const rawCoords = feature.geometry.coordinates;
+
+ [0, rawCoords.length - 1].forEach(i => {
+ const isStart = i === 0;
+ const snap = isStart ? feature._snapA : feature._snapB;
+ const enabledFlag = isStart ? '_snapAEnabled' : '_snapBEnabled';
+ const isEnabled = feature[enabledFlag] !== false;
+ const segSnap = !snap ? findNearestOsmWayPoint(rawCoords[i], SNAP_TOL * 3) : null;
+
+ let coord, color, canToggle;
+ if (snap) {
+ coord = snap.coord;
+ color = isEnabled ? '#28a745' : '#dc3545';
+ canToggle = true;
+ } else if (segSnap) {
+ coord = segSnap.coord;
+ color = isEnabled ? '#007bff' : '#dc3545';
+ canToggle = true;
+ } else {
+ coord = rawCoords[i];
+ color = '#ffc107';
+ canToggle = false;
+ }
+
+ const marker = L.circleMarker([coord[1], coord[0]], {
+ radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.9
+ });
+
+ if (canToggle) {
+ marker.bindTooltip(isEnabled ? `Click to disable ${isStart ? 'start' : 'end'} snap` : 'Click to re-enable snap', { sticky: true });
+ marker.on('click', function(e) {
+ L.DomEvent.stopPropagation(e);
+ feature[enabledFlag] = !isEnabled;
+ updateSnapPreview(feature);
+ if (popupLatlng) showMultiFeaturePopup(popupLatlng);
+ });
+ } else {
+ marker.bindTooltip(`${isStart ? 'Start' : 'End'}: dangling`, { sticky: true });
+ }
+
+ snapPreviewLayer.addLayer(marker);
+ });
+}
+
+window.toggleSnapA = function() {
+ if (selectedFeatures.length !== 1) return;
+ const feat = selectedFeatures[0];
+ feat._snapAEnabled = feat._snapAEnabled !== false ? false : true;
+ updateSnapPreview(feat);
+ if (popupLatlng) showMultiFeaturePopup(popupLatlng);
+};
+window.toggleSnapB = function() {
+ if (selectedFeatures.length !== 1) return;
+ const feat = selectedFeatures[0];
+ feat._snapBEnabled = feat._snapBEnabled !== false ? false : true;
+ updateSnapPreview(feat);
+ if (popupLatlng) showMultiFeaturePopup(popupLatlng);
+};
+
+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
+ };
+ acceptedFeatures.add(synthetic);
+ cacheSnapInfo(synthetic);
+ showStatus(`Imported "${osmTags.name || 'unnamed'}" from county data`, 'success');
+ if (popupLatlng) showMultiFeaturePopup(popupLatlng);
+};
+
+function connectivitySection(feature) {
+ if (!feature.geometry || feature.geometry.type !== 'LineString') return '';
+ const coords = feature.geometry.coordinates;
+ const snapA = findNearestOsmVertex(coords[0]);
+ const snapB = findNearestOsmVertex(coords[coords.length - 1]);
+ const segA = snapA ? null : findNearestOsmWayPoint(coords[0], SNAP_TOL * 3);
+ const segB = snapB ? null : findNearestOsmWayPoint(coords[coords.length - 1], SNAP_TOL * 3);
+
+ // 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;
+ }
+
+ function endpointHtml(label, snap, segSnap, toggleFn) {
+ const enabledFlag = toggleFn === 'toggleSnapA' ? '_snapAEnabled' : '_snapBEnabled';
+ const isEnabled = feature[enabledFlag] !== false;
+ const toggleBtn = (snap || segSnap)
+ ? ` `
+ : '';
+
+ 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 `✓ ${label}: snaps to ${typeStr} "${roadName}" (${m} m)${toggleBtn}
`;
+ }
+ 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 `✓ ${label}: will insert node into "${roadName}" (${m} m) on export${toggleBtn}
`;
+ }
+ const diffConn = findDiffConnection(label === 'Start' ? coords[0] : coords[coords.length - 1]);
+ if (diffConn) {
+ return `✓ ${label}: connects to new "${diffConn}"
`;
+ }
+ return `⚠ ${label}: no nearby road — dangling
`;
+ }
+
+ const crossings = findOsmCrossings(coords);
+ const newName = feature.properties && feature.properties.name;
+ let html = '';
+ html += '
Connectivity:';
+ html += endpointHtml('Start', snapA, segA, 'toggleSnapA');
+ html += endpointHtml('End', snapB, segB, 'toggleSnapB');
+ crossings.slice(0, 3).forEach(c => {
+ const sameName = newName && c.name && c.name.toLowerCase() === newName.toLowerCase();
+ const hint = sameName ? ' —
possible rename/reroute' : '';
+ html += `
⇄ Crosses "${c.name || 'unnamed'}"${hint}
`;
+ });
+ if (crossings.length > 3) html += `
… and ${crossings.length - 3} more
`;
+ html += '
';
+ 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;
+}
+
+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;
+
+ const tags = {};
+ Object.entries(props).forEach(([k, v]) => {
+ if (!['removed', 'status', 'accepted', 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions'].includes(k) &&
+ v !== null && v !== undefined && v !== '') tags[k] = String(v);
+ });
+
+ 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 = [`${plan.description}`];
+ 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 = ``;
+ 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) {
+ 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 modifiedWays = [];
+ const splitTailWays = [];
+ const deleteWayIds = []; // [{id, version}]
+ const deleteNodeIds = new Map(); // id -> version
+
+ acceptedAdded.forEach(feat => {
+ 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];
+ const tags = {};
+ Object.entries(feat.properties || {}).forEach(([k, v]) => {
+ if (!['removed', 'status', 'accepted', 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions'].includes(k) &&
+ v !== null && v !== undefined && v !== '') tags[k] = String(v);
+ });
+ 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 = {};
+ Object.entries(feat.properties || {}).forEach(([k, v]) => {
+ if (!['removed', 'status', 'accepted', 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions'].includes(k) &&
+ v !== null && v !== undefined && v !== '') tags[k] = String(v);
+ });
+ modifiedWays.push({ id: osmId, version: osmVersion, ndRefs, tags });
+ });
+
+ removalPlans.forEach((plan, i) => {
+ if (!checkedPlanIndices.has(i) || !plan.osmId) return;
+
+ 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, '"');
+ }
+
+ function wayXml(w, action) {
+ let x = ` \n`;
+ w.ndRefs.forEach(ref => { x += ` \n`; });
+ Object.entries(w.tags).forEach(([k, v]) => { x += ` \n`; });
+ return x + ' \n';
+ }
+
+ let xml = '\n\n\n';
+
+ referencedExisting.forEach(({ coord, version }, nid) => {
+ xml += ` \n`;
+ });
+ nodeRegistry.forEach((id, key) => {
+ if (id >= 0) return;
+ const [lon, lat] = key.split(',').map(Number);
+ xml += ` \n`;
+ });
+ if (referencedExisting.size > 0 || [...nodeRegistry.values()].some(id => id < 0)) xml += '\n';
+
+ newWays.forEach(w => { xml += wayXml(w, 'create'); });
+ modifiedWays.forEach(w => { xml += wayXml(w, 'modify'); });
+ splitTailWays.forEach(w => { xml += wayXml(w, 'create'); });
+ deleteWayIds.forEach(w => { xml += ` \n`; });
+ deleteNodeIds.forEach((version, nid) => { xml += ` \n`; });
+
+ xml += '\n\n';
+ return xml;
+}
+
+async function exportToOsm() {
+ if (acceptedFeatures.size === 0) {
+ showStatus('No accepted features to export', 'error');
+ return;
+ }
+
+ const acceptedAdded = [];
+ const acceptedRemoved = [];
+ acceptedFeatures.forEach(feat => {
+ const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
+ 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);
+ 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 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
@@ -1094,6 +1836,7 @@ async function loadFiles() {
// Create layers
createOsmLayer();
+ buildOsmNodeIndex();
createDiffLayer();
createCountyLayer();
@@ -1326,6 +2069,9 @@ document.addEventListener('DOMContentLoaded', function() {
// Save button
document.getElementById('saveButton').addEventListener('click', saveAcceptedItems);
+ // Export to JOSM button
+ document.getElementById('exportOsmButton').addEventListener('click', exportToOsm);
+
// Multi-select toggle button
document.getElementById('multiSelectToggle').addEventListener('click', function() {
multiSelectMode = !multiSelectMode;
diff --git a/web/templates/map.html b/web/templates/map.html
index 0ec0c14..1cac434 100644
--- a/web/templates/map.html
+++ b/web/templates/map.html
@@ -346,11 +346,25 @@
-
+
+
+
+
+
+
Review Auto-Fix Plans
+
These removed roads need splitting. Uncheck any to skip (old way excluded from export).
+
+
+
+
+
+
+
+