From cc925fd3457c75a88f0dff6cc86319bb8d073072 Mon Sep 17 00:00:00 2001 From: zyphlar Date: Wed, 8 Jul 2026 14:57:47 -0700 Subject: [PATCH] improved josm; snapping --- diff-highways.py | 18 +- docker-compose.yml | 7 +- download-overpass.py | 27 +- web/static/map.js | 746 +++++++++++++++++++++++++++++++++++++++++ web/templates/map.html | 16 +- 5 files changed, 805 insertions(+), 9 deletions(-) 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 @@ - + + + + +