// Global state let map; let osmLayer; let diffLayer; let countyLayer; let osmData = null; let diffData = null; let countyData = null; let selectedFeatures = []; let selectedLayers = []; let acceptedFeatures = new Set(); let rejectedFeatures = new Set(); let featurePopup = null; let layerOrder = ['diff', 'osm', 'county']; // Default layer order (top to bottom) // Exclusions loaded from server let exclusions = []; // Drag selection state let isDragging = false; let dragStartPoint = null; let selectionBox = null; let multiSelectMode = false; // Mobile controls state 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); L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap contributors © CARTO', subdomains: 'abcd', maxZoom: 20 }).addTo(map); snapPreviewLayer = L.layerGroup().addTo(map); // Create custom panes for layer ordering map.createPane('osmPane'); map.createPane('diffPane'); map.createPane('countyPane'); // Set initial z-indices for panes based on layerOrder updateLayerZIndex(); // Setup drag selection setupDragSelection(); } // Setup drag selection functionality function setupDragSelection() { const mapContainer = map.getContainer(); map.on('mousedown', function(e) { // Only start drag selection with shift key or multi-select mode enabled if (!e.originalEvent.shiftKey && !multiSelectMode) return; isDragging = true; dragStartPoint = e.containerPoint; // Create selection box selectionBox = L.DomUtil.create('div', 'selection-box', mapContainer); selectionBox.style.left = dragStartPoint.x + 'px'; selectionBox.style.top = dragStartPoint.y + 'px'; // Prevent map panning while dragging map.dragging.disable(); e.originalEvent.preventDefault(); }); map.on('mousemove', function(e) { if (!isDragging || !selectionBox) return; const currentPoint = e.containerPoint; const minX = Math.min(dragStartPoint.x, currentPoint.x); const minY = Math.min(dragStartPoint.y, currentPoint.y); const width = Math.abs(currentPoint.x - dragStartPoint.x); const height = Math.abs(currentPoint.y - dragStartPoint.y); selectionBox.style.left = minX + 'px'; selectionBox.style.top = minY + 'px'; selectionBox.style.width = width + 'px'; selectionBox.style.height = height + 'px'; }); map.on('mouseup', function(e) { if (!isDragging) return; isDragging = false; map.dragging.enable(); if (selectionBox) { const endPoint = e.containerPoint; // Calculate bounds const minX = Math.min(dragStartPoint.x, endPoint.x); const minY = Math.min(dragStartPoint.y, endPoint.y); const maxX = Math.max(dragStartPoint.x, endPoint.x); const maxY = Math.max(dragStartPoint.y, endPoint.y); const bounds = L.latLngBounds( map.containerPointToLatLng([minX, minY]), map.containerPointToLatLng([maxX, maxY]) ); // Select features within bounds selectFeaturesInBounds(bounds); // Remove selection box mapContainer.removeChild(selectionBox); selectionBox = null; } dragStartPoint = null; }); } // Select features within bounds function selectFeaturesInBounds(bounds) { clearSelection(); // Only select from diff layer (OSM/county items can't be accepted/rejected) const layers = [ { layer: diffLayer, data: diffData, type: 'diff' } ]; layers.forEach(({ layer, data, type }) => { if (!layer || !data) return; layer.eachLayer(function(leafletLayer) { const feature = leafletLayer.feature; if (!feature) return; let isInBounds = false; // Check if feature is within bounds if (feature.geometry.type === 'Point') { const coords = feature.geometry.coordinates; const latlng = L.latLng(coords[1], coords[0]); isInBounds = bounds.contains(latlng); } else if (feature.geometry.type === 'LineString') { // Check if any point of the linestring is within bounds const coords = feature.geometry.coordinates; isInBounds = coords.some(coord => { const latlng = L.latLng(coord[1], coord[0]); return bounds.contains(latlng); }); } if (isInBounds) { selectedFeatures.push(feature); selectedLayers.push({ layer: leafletLayer, type: type }); // Highlight selected feature const isPoint = feature.geometry.type === 'Point'; if (isPoint) { leafletLayer.setStyle({ radius: 9, fillColor: '#ffc107', color: '#ff9800', weight: 2, opacity: 1, fillOpacity: 1 }); } else { leafletLayer.setStyle({ weight: 6, opacity: 1, color: '#ffc107' }); } } }); }); if (selectedFeatures.length > 0) { // Calculate center point for popup const center = bounds.getCenter(); showMultiFeaturePopup(center); } } // Clear current selection function clearSelection() { // Restore original styles for previously selected features selectedLayers.forEach(({ layer, type }) => { const feature = layer.feature; const isPoint = feature.geometry.type === 'Point'; if (isPoint) { const markerStyleFunc = type === 'diff' ? diffMarkerStyle : type === 'osm' ? osmMarkerStyle : countyMarkerStyle; layer.setStyle(markerStyleFunc(feature)); } else { const styleFunc = type === 'diff' ? diffStyle : type === 'osm' ? osmStyle : countyStyle; layer.setStyle(styleFunc(feature)); } }); selectedFeatures = []; selectedLayers = []; if (snapPreviewLayer) snapPreviewLayer.clearLayers(); if (featurePopup) { map.closePopup(featurePopup); featurePopup = null; } } // Calculate bounds for all loaded layers function calculateBounds() { const bounds = L.latLngBounds([]); let hasData = false; if (osmData && osmData.features.length > 0) { L.geoJSON(osmData).eachLayer(layer => { if (layer.getBounds) { bounds.extend(layer.getBounds()); } else if (layer.getLatLng) { bounds.extend(layer.getLatLng()); } }); hasData = true; } if (diffData && diffData.features.length > 0) { L.geoJSON(diffData).eachLayer(layer => { if (layer.getBounds) { bounds.extend(layer.getBounds()); } else if (layer.getLatLng) { bounds.extend(layer.getLatLng()); } }); hasData = true; } if (countyData && countyData.features.length > 0) { L.geoJSON(countyData).eachLayer(layer => { if (layer.getBounds) { bounds.extend(layer.getBounds()); } else if (layer.getLatLng) { bounds.extend(layer.getLatLng()); } }); hasData = true; } if (hasData && bounds.isValid()) { map.fitBounds(bounds, { padding: [50, 50] }); } } // Style functions for lines function osmStyle(feature) { return { color: '#4a4a4a', weight: 3, opacity: 0.7 }; } // Style functions for point markers (addresses) function osmMarkerStyle(feature) { return { radius: 6, fillColor: '#4a4a4a', color: '#333', weight: 1, opacity: 0.8, fillOpacity: 0.6 }; } function diffStyle(feature) { // Check if feature is accepted or rejected if (acceptedFeatures.has(feature)) { return { color: '#007bff', weight: 3, opacity: 0.8 }; } if (rejectedFeatures.has(feature)) { return { color: '#ff8c00', weight: 3, opacity: 0.8 }; } const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True'); return { color: isRemoved ? '#ff0000' : '#00ff00', weight: 3, opacity: 0.8 }; } function diffMarkerStyle(feature) { // Check if feature is accepted or rejected if (acceptedFeatures.has(feature)) { return { radius: 6, fillColor: '#007bff', color: '#0056b3', weight: 1, opacity: 0.8, fillOpacity: 0.7 }; } if (rejectedFeatures.has(feature)) { return { radius: 6, fillColor: '#ff8c00', color: '#d47200', weight: 1, opacity: 0.8, fillOpacity: 0.7 }; } const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True'); return { radius: 6, fillColor: isRemoved ? '#ff0000' : '#00ff00', color: isRemoved ? '#cc0000' : '#00cc00', weight: 1, opacity: 0.8, fillOpacity: 0.7 }; } function countyStyle(feature) { return { color: '#ff00ff', weight: 3, opacity: 0.8 }; } function countyMarkerStyle(feature) { return { radius: 6, fillColor: '#ff00ff', color: '#cc00cc', weight: 1, opacity: 0.8, fillOpacity: 0.6 }; } // Filter function for OSM features function shouldShowOsmFeature(feature) { const props = feature.properties || {}; const isService = props.highway === 'service' || props.highway === 'track'; const isUnclassified = props.highway === 'unclassified'; const hideService = document.getElementById('hideService').checked; const hideUnclassified = document.getElementById('hideUnclassified').checked; if (isService && hideService) return false; if (isUnclassified && hideUnclassified) return false; return true; } // Create layer for OSM data function createOsmLayer() { if (osmLayer) { map.removeLayer(osmLayer); } if (!osmData) return; osmLayer = L.geoJSON(osmData, { style: osmStyle, filter: shouldShowOsmFeature, pane: 'osmPane', pointToLayer: function(feature, latlng) { return L.circleMarker(latlng, osmMarkerStyle(feature)); }, onEachFeature: function(feature, layer) { const isPoint = feature.geometry.type === 'Point'; layer.on('click', function(e) { L.DomEvent.stopPropagation(e); selectFeature(feature, layer, e, 'osm'); }); layer.on('mouseover', function(e) { const isSelected = selectedLayers.some(l => l.layer === layer); if (!isSelected) { if (isPoint) { layer.setStyle({ radius: 8, fillOpacity: 1 }); } else { layer.setStyle({ weight: 5, opacity: 1 }); } } }); layer.on('mouseout', function(e) { const isSelected = selectedLayers.some(l => l.layer === layer); if (!isSelected) { if (isPoint) { layer.setStyle(osmMarkerStyle(feature)); } else { layer.setStyle(osmStyle(feature)); } } }); } }); // Only add to map if checkbox is checked if (document.getElementById('osmToggle').checked) { osmLayer.addTo(map); } updateLayerZIndex(); } // Check if a feature matches any server-side exclusion function isExcluded(feature) { if (!exclusions.length) return false; const props = feature.properties || {}; return exclusions.some(excl => { const val = props[excl.field]; return val !== undefined && val !== null && String(val) === String(excl.value); }); } // Filter function for diff features function shouldShowFeature(feature) { if (isExcluded(feature)) return false; const props = feature.properties || {}; const isRemoved = props.removed === true || props.removed === 'True'; const isService = props.highway === 'service' || props.highway === 'track'; const isUnclassified = props.highway === 'unclassified'; const showAdded = document.getElementById('showAdded').checked; const showRemoved = document.getElementById('showRemoved').checked; const hideService = document.getElementById('hideService').checked; const hideUnclassified = document.getElementById('hideUnclassified').checked; // Check removed/added filter if (isRemoved && !showRemoved) return false; if (!isRemoved && !showAdded) return false; // Check service/track and unclassified filters if (isService && hideService) return false; if (isUnclassified && hideUnclassified) return false; return true; } // Create layer for diff data with click handlers function createDiffLayer() { if (diffLayer) { map.removeLayer(diffLayer); } if (!diffData) return; diffLayer = L.geoJSON(diffData, { style: diffStyle, filter: shouldShowFeature, pane: 'diffPane', pointToLayer: function(feature, latlng) { return L.circleMarker(latlng, diffMarkerStyle(feature)); }, onEachFeature: function(feature, layer) { const isPoint = feature.geometry.type === 'Point'; layer.on('click', function(e) { L.DomEvent.stopPropagation(e); selectFeature(feature, layer, e, 'diff'); }); layer.on('mouseover', function(e) { const isSelected = selectedLayers.some(l => l.layer === layer); if (!isSelected) { if (isPoint) { layer.setStyle({ radius: 8, fillOpacity: 1 }); } else { layer.setStyle({ weight: 5, opacity: 1 }); } } }); layer.on('mouseout', function(e) { const isSelected = selectedLayers.some(l => l.layer === layer); if (!isSelected) { if (isPoint) { layer.setStyle(diffMarkerStyle(feature)); } else { layer.setStyle(diffStyle(feature)); } } }); } }); // Only add to map if checkbox is checked if (document.getElementById('diffToggle').checked) { diffLayer.addTo(map); } updateLayerZIndex(); } // Filter function for county features function shouldShowCountyFeature(feature) { const props = feature.properties || {}; const isService = props.highway === 'service' || props.highway === 'track'; const isUnclassified = props.highway === 'unclassified'; const hideService = document.getElementById('hideService').checked; const hideUnclassified = document.getElementById('hideUnclassified').checked; if (isService && hideService) return false; if (isUnclassified && hideUnclassified) return false; return true; } // Create layer for county data function createCountyLayer() { if (countyLayer) { map.removeLayer(countyLayer); } if (!countyData) return; countyLayer = L.geoJSON(countyData, { style: countyStyle, filter: shouldShowCountyFeature, pane: 'countyPane', pointToLayer: function(feature, latlng) { return L.circleMarker(latlng, countyMarkerStyle(feature)); }, onEachFeature: function(feature, layer) { const isPoint = feature.geometry.type === 'Point'; layer.on('click', function(e) { L.DomEvent.stopPropagation(e); selectFeature(feature, layer, e, 'county'); }); layer.on('mouseover', function(e) { const isSelected = selectedLayers.some(l => l.layer === layer); if (!isSelected) { if (isPoint) { layer.setStyle({ radius: 8, fillOpacity: 1 }); } else { layer.setStyle({ weight: 5, opacity: 1 }); } } }); layer.on('mouseout', function(e) { const isSelected = selectedLayers.some(l => l.layer === layer); if (!isSelected) { if (isPoint) { layer.setStyle(countyMarkerStyle(feature)); } else { layer.setStyle(countyStyle(feature)); } } }); } }); // County layer is hidden by default if (document.getElementById('countyToggle').checked) { countyLayer.addTo(map); } updateLayerZIndex(); } // Select a feature from any layer function selectFeature(feature, layer, e, layerType = 'diff') { // Check if this is a multi-select attempt const isMultiSelect = (e.originalEvent && e.originalEvent.shiftKey) || multiSelectMode; // Only allow multi-selection of diff layer features (OSM/county can't be accepted/rejected) if (layerType !== 'diff' && isMultiSelect) { return; // Block multi-selection of OSM/county layers } if (isMultiSelect) { // Check if already selected const alreadySelected = selectedFeatures.includes(feature); if (alreadySelected) { // Remove from selection const index = selectedFeatures.indexOf(feature); selectedFeatures.splice(index, 1); const layerInfo = selectedLayers[index]; selectedLayers.splice(index, 1); // Restore original style const isPoint = feature.geometry.type === 'Point'; if (isPoint) { const markerStyleFunc = layerType === 'diff' ? diffMarkerStyle : layerType === 'osm' ? osmMarkerStyle : countyMarkerStyle; layer.setStyle(markerStyleFunc(feature)); } else { const styleFunc = layerType === 'diff' ? diffStyle : layerType === 'osm' ? osmStyle : countyStyle; layer.setStyle(styleFunc(feature)); } } else { // Add to selection selectedFeatures.push(feature); selectedLayers.push({ layer: layer, type: layerType }); // Highlight const isPoint = feature.geometry.type === 'Point'; if (isPoint) { layer.setStyle({ radius: 9, fillColor: '#ffc107', color: '#ff9800', weight: 2, opacity: 1, fillOpacity: 1 }); } else { layer.setStyle({ weight: 6, opacity: 1, color: '#ffc107' }); } } // Update popup with current selection if (selectedFeatures.length > 0) { showMultiFeaturePopup(e.latlng); } else { clearSelection(); } } else { // Regular click - replace selection clearSelection(); selectedFeatures = [feature]; selectedLayers = [{ layer: layer, type: layerType }]; const isPoint = feature.geometry.type === 'Point'; if (isPoint) { layer.setStyle({ radius: 9, fillColor: '#ffc107', color: '#ff9800', weight: 2, opacity: 1, fillOpacity: 1 }); } else { layer.setStyle({ weight: 6, opacity: 1, color: '#ffc107' }); } // Show popup for single or multiple features showMultiFeaturePopup(e.latlng); 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) { map.closePopup(featurePopup); } let html = '
'; // Show count html += `
Selected: ${selectedFeatures.length} feature${selectedFeatures.length > 1 ? 's' : ''}
`; // Aggregate properties JOSM-style const allKeys = new Set(); selectedFeatures.forEach(feature => { Object.keys(feature.properties || {}).forEach(key => { if (key !== 'removed') allKeys.add(key); }); }); // Priority order for display const priorityOrder = { 'addr:housenumber': 0, 'addr:street': 1, 'addr:unit': 2, 'addr:city': 3, 'addr:postcode': 4, 'addr:state': 5, 'name': 10, 'highway': 11, // County road fields 'Lifecycle': 20, 'LIFECYCLE': 20, 'FullStreetN': 21, 'FullName': 21, 'StreetClass': 22, 'STRCLASS': 22, 'SpeedLimit': 23, 'SPEEDLIMIT': 23, 'LeftCity': 24, 'RightCity': 25, 'LeftZip': 26, 'RightZip': 27, }; const sortedKeys = Array.from(allKeys).sort((a, b) => { const aPriority = priorityOrder[a] ?? 999; const bPriority = priorityOrder[b] ?? 999; if (aPriority !== bPriority) { return aPriority - bPriority; } return a.localeCompare(b); }); // For each property, check if all values are the same html += '
'; for (const key of sortedKeys) { const values = selectedFeatures .map(f => f.properties[key]) .filter(v => v !== null && v !== undefined); if (values.length === 0) continue; const uniqueValues = [...new Set(values.map(v => String(v)))]; let displayValue; if (uniqueValues.length === 1) { // All values are the same displayValue = uniqueValues[0]; } else { // Different values displayValue = `<${uniqueValues.length} different values>`; } html += `
${key}: ${displayValue}
`; } html += '
'; // Show layer types if mixed const layerTypes = selectedLayers.map(l => l.type); const uniqueLayerTypes = [...new Set(layerTypes)]; if (uniqueLayerTypes.length > 1) { html += `
Layers: ${uniqueLayerTypes.join(', ')}
`; } else { html += `
Layer: ${uniqueLayerTypes[0].toUpperCase()}
`; } // Show status (Added/Removed) for diff layer features const hasDiffFeatures = selectedLayers.some(l => l.type === 'diff'); if (hasDiffFeatures) { const addedCount = selectedFeatures.filter(f => !f.properties || f.properties.removed !== true && f.properties.removed !== 'True').length; const removedCount = selectedFeatures.filter(f => f.properties && (f.properties.removed === true || f.properties.removed === 'True')).length; if (selectedFeatures.length === 1) { // Single selection - show specific status const isRemoved = selectedFeatures[0].properties && (selectedFeatures[0].properties.removed === true || selectedFeatures[0].properties.removed === 'True'); 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
`; } else if (addedCount > 0) { html += `
Status: All Added (Green)
`; } else if (removedCount > 0) { html += `
Status: All Removed (Red)
`; } } // Show accept/reject status if any are from diff layer if (hasDiffFeatures) { const acceptedCount = selectedFeatures.filter(f => acceptedFeatures.has(f)).length; const rejectedCount = selectedFeatures.filter(f => rejectedFeatures.has(f)).length; if (acceptedCount > 0 || rejectedCount > 0) { html += '
'; if (acceptedCount > 0) html += `
✓ ${acceptedCount} accepted
`; if (rejectedCount > 0) html += `
✗ ${rejectedCount} rejected
`; html += '
'; } // Show accept/reject buttons only if all selected features are from diff layer const allDiff = selectedLayers.every(l => l.type === 'diff'); if (allDiff) { const buttonText = selectedFeatures.length === 1 ? '' : ' All'; html += '
'; html += ``; html += ``; html += '
'; html += '
'; html += ``; html += '
'; } } // 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 featurePopup = L.popup({ maxWidth: 300, closeButton: true, autoClose: false, closeOnClick: false }) .setLatLng(latlng) .setContent(html) .openOn(map); // Handle popup close featurePopup.on('remove', function() { clearSelection(); }); } // Accept all selected features function acceptAllFeatures() { if (selectedFeatures.length === 0) return; selectedFeatures.forEach((feature, index) => { // Remove from rejected if present rejectedFeatures.delete(feature); // Add to accepted acceptedFeatures.add(feature); // Cache snap endpoints for JOSM export const isAdded = !feature.properties || (feature.properties.removed !== true && feature.properties.removed !== 'True'); if (isAdded && osmVertices.length > 0) cacheSnapInfo(feature); // Update layer style const layerInfo = selectedLayers[index]; if (layerInfo && layerInfo.layer) { const isPoint = feature.geometry.type === 'Point'; if (isPoint) { layerInfo.layer.setStyle(diffMarkerStyle(feature)); } else { layerInfo.layer.setStyle(diffStyle(feature)); } } }); // Close popup if (featurePopup) { map.closePopup(featurePopup); } // Clear selection clearSelection(); // Enable save button updateSaveButton(); showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success'); } // Reject all selected features function rejectAllFeatures() { if (selectedFeatures.length === 0) return; selectedFeatures.forEach((feature, index) => { // Remove from accepted if present acceptedFeatures.delete(feature); // Add to rejected rejectedFeatures.add(feature); // Update layer style const layerInfo = selectedLayers[index]; if (layerInfo && layerInfo.layer) { const isPoint = feature.geometry.type === 'Point'; if (isPoint) { layerInfo.layer.setStyle(diffMarkerStyle(feature)); } else { layerInfo.layer.setStyle(diffStyle(feature)); } } }); // Close popup if (featurePopup) { map.closePopup(featurePopup); } // Clear selection clearSelection(); // Enable save button updateSaveButton(); showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success'); } // Exclude selected features by name/street and refresh diff layer async function excludeSelectedFeatures() { if (selectedFeatures.length === 0) return; const toExclude = []; const seen = new Set(); for (const feature of selectedFeatures) { const props = feature.properties || {}; let field, value; if (props['name']) { field = 'name'; value = props['name']; } else if (props['addr:street']) { field = 'addr:street'; value = props['addr:street']; } else { continue; } const key = `${field}\0${value}`; if (!seen.has(key)) { seen.add(key); toExclude.push({ field, value }); } } if (toExclude.length === 0) { showStatus('No name or street property found to exclude by.', 'error'); return; } try { const resp = await fetch('/api/exclusions/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ exclusions: toExclude }) }); const data = await resp.json(); if (data.error) { if (resp.status === 401) { showStatus('Not authenticated — please log in to exclude features.', 'error'); } else { showStatus(`Error: ${data.error}`, 'error'); } return; } } catch (err) { showStatus(`Error: ${err.message}`, 'error'); return; } // Update local list and refresh for (const e of toExclude) { if (!exclusions.some(ex => ex.field === e.field && ex.value === e.value)) { exclusions.push(e); } } if (featurePopup) map.closePopup(featurePopup); clearSelection(); createDiffLayer(); const names = toExclude.map(e => e.value).join(', '); showStatus(`Excluded: ${names}`, 'success'); } // Expose functions globally for onclick handlers 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 async function loadFromServer(url) { const response = await fetch(url); if (!response.ok) { if (response.status === 404) { return null; // File doesn't exist } throw new Error(`Failed to load ${url}: ${response.statusText}`); } return await response.json(); } // Load all files from server async function loadFiles() { try { showStatus('Loading data from server...', 'success'); const county = document.getElementById('countySelect').value; const dataType = document.getElementById('dataTypeSelect').value; loadedCounty = county; loadedDataType = dataType; // Load exclusions try { const exclResp = await fetch('/api/exclusions'); if (exclResp.ok) { const exclData = await exclResp.json(); exclusions = exclData.exclusions || []; } } catch (_) {} // Build file paths based on county and data type let osmFile, diffFile, countyFile, diffAddedFile, diffRemovedFile; if (dataType === 'roads') { osmFile = `${county}/osm-roads.geojson`; diffFile = `${county}/diff-roads.geojson`; countyFile = `${county}/county-roads.geojson`; } else if (dataType === 'paths') { osmFile = `${county}/osm-paths.geojson`; diffFile = `${county}/diff-paths.geojson`; countyFile = `${county}/county-paths.geojson`; } else if (dataType === 'addresses') { osmFile = `${county}/osm-addresses.geojson`; diffAddedFile = `${county}/addresses-to-add.geojson`; diffRemovedFile = `${county}/addresses-potentially-removed.geojson`; countyFile = `${county}/addresses.shp_converted.geojson`; } // Load files from server osmData = osmFile ? await loadFromServer(`/data/${osmFile}`) : null; countyData = countyFile ? await loadFromServer(`/data/${countyFile}`) : null; // For addresses, load both added and removed files and combine them if (dataType === 'addresses' && (diffAddedFile || diffRemovedFile)) { const addedData = diffAddedFile ? await loadFromServer(`/data/${diffAddedFile}`) : null; const removedData = diffRemovedFile ? await loadFromServer(`/data/${diffRemovedFile}`) : null; // Combine both datasets into diffData diffData = { type: 'FeatureCollection', features: [] }; // Add "removed" features FIRST (red) - they will render beneath if (removedData && removedData.features) { removedData.features.forEach(feature => { if (!feature.properties) feature.properties = {}; feature.properties.removed = true; diffData.features.push(feature); }); } // Add "added" features SECOND (green) - they will render on top if (addedData && addedData.features) { addedData.features.forEach(feature => { // Ensure removed property is not set or is false if (!feature.properties) feature.properties = {}; feature.properties.removed = false; diffData.features.push(feature); }); } // If no features loaded, set to null if (diffData.features.length === 0) { diffData = null; } } else { // For roads/paths, load single diff file diffData = diffFile ? await loadFromServer(`/data/${diffFile}`) : null; } if (!osmData && !diffData && !countyData) { showStatus(`No data files found for ${county} ${dataType}. Run the processing scripts first.`, 'error'); return; } // Create layers createOsmLayer(); buildOsmNodeIndex(); createDiffLayer(); createCountyLayer(); // Fit bounds to smallest layer calculateBounds(); let loadedFiles = []; if (osmData) loadedFiles.push('OSM'); if (diffData) loadedFiles.push('Diff'); if (countyData) loadedFiles.push('County'); showStatus(`Loaded ${loadedFiles.join(', ')} data for ${county} ${dataType}`, 'success'); // Enable save button if we have diff data document.getElementById('saveButton').disabled = !diffData; } catch (error) { showStatus(error.message, 'error'); console.error(error); } } // Save accepted items to separate files (added-approved.geojson and removed-approved.geojson) async function saveAcceptedItems() { if (!diffData || acceptedFeatures.size === 0) { showStatus('No accepted features to save', 'error'); return; } try { // Separate accepted features into added and removed const acceptedAdded = []; const acceptedRemoved = []; diffData.features.forEach(feature => { if (acceptedFeatures.has(feature)) { // Clone feature and remove status/approved/removed properties const cleanFeature = { type: feature.type, geometry: feature.geometry, properties: {} }; // Copy all properties except status, accepted, and removed Object.keys(feature.properties).forEach(key => { if (key !== 'status' && key !== 'accepted' && key !== 'removed') { cleanFeature.properties[key] = feature.properties[key]; } }); // Determine if added or removed const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True'); if (isRemoved) { acceptedRemoved.push(cleanFeature); } else { acceptedAdded.push(cleanFeature); } } }); const dataDate = new Date().toISOString().slice(0, 10).replace(/-/g, ''); const prefix = [loadedCounty, loadedDataType, dataDate].filter(Boolean).join('-'); function triggerDownload(features, label) { const blob = new Blob([JSON.stringify({ type: 'FeatureCollection', features }, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${prefix}-${label}.geojson`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } if (acceptedAdded.length > 0) triggerDownload(acceptedAdded, 'added-approved'); if (acceptedRemoved.length > 0) triggerDownload(acceptedRemoved, 'removed-approved'); showStatus(`Saved ${acceptedAdded.length} added, ${acceptedRemoved.length} removed (approved only)`, 'success'); } catch (error) { showStatus(`Save failed: ${error.message}`, 'error'); console.error(error); } } // Show status message function showStatus(message, type) { const status = document.getElementById('status'); status.textContent = message; status.className = `status ${type}`; setTimeout(() => { status.classList.add('hidden'); }, 3000); } // Update pane z-index based on order function updateLayerZIndex() { const panes = { 'osm': 'osmPane', 'diff': 'diffPane', 'county': 'countyPane' }; const layers = { 'osm': osmLayer, 'diff': diffLayer, 'county': countyLayer }; // Reverse index so first item in list is on top // Use higher base z-index (650) to ensure we're above overlay pane layerOrder.forEach((layerName, index) => { const paneName = panes[layerName]; const pane = map.getPane(paneName); if (pane) { const zIndex = 650 + (layerOrder.length - 1 - index); pane.style.zIndex = zIndex; } }); // Also bring layers to front in reverse order (last first, so first ends up on top) for (let i = layerOrder.length - 1; i >= 0; i--) { const layer = layers[layerOrder[i]]; if (layer && map.hasLayer(layer)) { layer.bringToFront(); } } } // Toggle layer visibility function toggleLayer(layerId, layer) { const checkbox = document.getElementById(layerId); if (checkbox.checked && layer) { if (!map.hasLayer(layer)) { map.addLayer(layer); } } else if (layer) { if (map.hasLayer(layer)) { map.removeLayer(layer); } } // Always update z-index after toggling any layer updateLayerZIndex(); } // Event listeners document.addEventListener('DOMContentLoaded', function() { initMap(); // Mobile hamburger menu toggle const hamburgerButton = document.getElementById('hamburgerButton'); const controls = document.getElementById('controls'); const overlay = document.getElementById('overlay'); function toggleControls() { mobileControlsOpen = !mobileControlsOpen; controls.classList.toggle('open'); hamburgerButton.classList.toggle('active'); overlay.classList.toggle('active'); } function closeControls() { mobileControlsOpen = false; controls.classList.remove('open'); hamburgerButton.classList.remove('active'); overlay.classList.remove('active'); } // Expose closeControls globally for other functions window.closeMobileControls = closeControls; hamburgerButton.addEventListener('click', toggleControls); overlay.addEventListener('click', closeControls); // Layer toggles document.getElementById('osmToggle').addEventListener('change', function() { toggleLayer('osmToggle', osmLayer); }); document.getElementById('diffToggle').addEventListener('change', function() { toggleLayer('diffToggle', diffLayer); }); document.getElementById('countyToggle').addEventListener('change', function() { toggleLayer('countyToggle', countyLayer); }); // Diff filter toggles document.getElementById('showAdded').addEventListener('change', function() { createDiffLayer(); }); document.getElementById('showRemoved').addEventListener('change', function() { createDiffLayer(); }); document.getElementById('hideService').addEventListener('change', function() { createDiffLayer(); createOsmLayer(); createCountyLayer(); }); document.getElementById('hideUnclassified').addEventListener('change', function() { createDiffLayer(); createOsmLayer(); createCountyLayer(); }); // Click on empty map area closes any open popup and clears selection map.on('click', function(e) { if (multiSelectMode) return; if (e.originalEvent && e.originalEvent.shiftKey) return; clearSelection(); }); // Load button document.getElementById('loadButton').addEventListener('click', function() { loadFiles(); // Auto-close controls on mobile after clicking load if (window.innerWidth <= 768) { setTimeout(closeControls, 300); } }); // 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; const button = document.getElementById('multiSelectToggle'); if (multiSelectMode) { button.style.background = '#007bff'; button.textContent = 'Multi-Select: ON'; map.getContainer().classList.add('multi-select-active'); } else { button.style.background = '#6c757d'; button.textContent = 'Multi-Select: OFF'; map.getContainer().classList.remove('multi-select-active'); // Clear selection when turning off multi-select mode clearSelection(); } }); // Drag and drop for layer reordering const layerList = document.getElementById('layerList'); const layerItems = layerList.querySelectorAll('.layer-item'); let draggedElement = null; layerItems.forEach(item => { item.addEventListener('dragstart', function(e) { draggedElement = this; this.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); item.addEventListener('dragend', function(e) { this.classList.remove('dragging'); draggedElement = null; }); item.addEventListener('dragover', function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (this === draggedElement) return; const afterElement = getDragAfterElement(layerList, e.clientY); if (afterElement == null) { layerList.appendChild(draggedElement); } else { layerList.insertBefore(draggedElement, afterElement); } }); item.addEventListener('drop', function(e) { e.preventDefault(); // Update layer order based on new DOM order layerOrder = Array.from(layerList.querySelectorAll('.layer-item')) .map(item => item.dataset.layer); updateLayerZIndex(); }); }); function getDragAfterElement(container, y) { const draggableElements = [...container.querySelectorAll('.layer-item:not(.dragging)')]; return draggableElements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }).element; } });