// 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 = '';
// Tracks county features that have been imported (so countyStyle can highlight them)
let importedCountyFeatures = new Set();
// OSM node index for JOSM export snapping
let osmVertices = [];
let osmNodeById = new Map();
let osmCoordToNodeId = new Map();
let osmNodeVersionById = new Map();
let SNAP_TOL = 10 / 111320; // default 10m in degrees; updated from UI
// Snap preview layer and popup state
let snapPreviewLayer = null;
let popupLatlng = null;
// 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');
map.createPane('snapPane');
map.getPane('snapPane').style.zIndex = 660; // above road panes (~652), below popups (700)
// Set initial z-indices for panes based on layerOrder
updateLayerZIndex();
// Setup drag selection
setupDragSelection();
}
// Setup drag selection functionality
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 = [];
updateSnapPreview();
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) {
if (importedCountyFeatures.has(feature)) {
return { color: '#007bff', weight: 3, opacity: 0.8 };
}
return { color: '#ff00ff', weight: 3, opacity: 0.8 };
}
function countyMarkerStyle(feature) {
if (importedCountyFeatures.has(feature)) {
return { radius: 6, fillColor: '#007bff', color: '#0056b3', weight: 1, opacity: 0.8, fillOpacity: 0.7 };
}
return { radius: 6, fillColor: '#ff00ff', color: '#cc00cc', weight: 1, opacity: 0.8, fillOpacity: 0.6 };
}
// Filter function for OSM features
function shouldShowOsmFeature(feature) {
const props = feature.properties || {};
const isService = props.highway === 'service' || props.highway === 'track';
const isUnclassified = props.highway === 'unclassified';
const hideService = document.getElementById('hideService').checked;
const hideUnclassified = document.getElementById('hideUnclassified').checked;
if (isService && hideService) return false;
if (isUnclassified && hideUnclassified) return false;
return true;
}
// Create layer for OSM data
function createOsmLayer() {
if (osmLayer) {
map.removeLayer(osmLayer);
}
if (!osmData) return;
osmLayer = L.geoJSON(osmData, {
style: osmStyle,
filter: shouldShowOsmFeature,
pane: 'osmPane',
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);
updateSnapPreview();
}
}
// Show popup for multiple features (JOSM-style aggregation)
function showMultiFeaturePopup(latlng) {
if (selectedFeatures.length === 0) return;
popupLatlng = latlng;
// Remove old popup without triggering clearSelection
if (featurePopup) {
featurePopup.off('remove');
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);
});
// Compute layer types early so property display can adapt
const layerTypes = selectedLayers.map(l => l.type);
const uniqueLayerTypes = [...new Set(layerTypes)];
const allCounty = uniqueLayerTypes.length === 1 && uniqueLayerTypes[0] === 'county';
// For each property, check if all values are the same
let propsHtml = '
';
for (const key of sortedKeys) {
if (key === 'osm_nodes' || key === 'osm_node_versions') continue;
const values = selectedFeatures
.map(f => f.properties[key])
.filter(v => v !== null && v !== undefined);
if (values.length === 0) continue;
const uniqueValues = [...new Set(values.map(v => String(v)))];
let displayValue;
if (uniqueValues.length === 1) {
displayValue = uniqueValues[0];
} else {
displayValue = `<${uniqueValues.length} different values>`;
}
propsHtml += `
${key}: ${displayValue}
`;
}
propsHtml += '
';
if (allCounty) {
html += `
Raw county data ${propsHtml}`;
} else {
html += propsHtml;
}
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)
`;
}
// Per-feature connectivity / snap toggles for multiselect
if (selectedFeatures.length > 1 && osmVertices.length > 0) {
selectedLayers.forEach(({ type }, i) => {
if (type !== 'diff') return;
const feat = selectedFeatures[i];
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
if (isRemoved) return;
const name = (feat.properties && feat.properties.name) || `Road ${i + 1}`;
html += `
${name}
`;
html += connectivitySection(feat, i);
});
}
}
// 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 += `Accept${buttonText} `;
html += `Reject${buttonText} `;
html += '
';
html += '
';
html += `Exclude${buttonText} from future diffs `;
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 += `
Import as new road `;
}
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._segSnapA = feature._snapA ? null : findNearestOsmWayPoint(coords[0], SNAP_TOL * 3);
feature._segSnapB = feature._snapB ? null : findNearestOsmWayPoint(coords[coords.length - 1], SNAP_TOL * 3);
feature._snapTol = SNAP_TOL;
feature._snappedCoords = [
feature._snapA ? feature._snapA.coord : coords[0],
...coords.slice(1, -1),
feature._snapB ? feature._snapB.coord : coords[coords.length - 1]
];
}
function precalculateSnaps() {
if (!diffData || osmVertices.length === 0) return;
diffData.features.forEach(feat => {
if (!feat.geometry || feat.geometry.type !== 'LineString') return;
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
if (!isRemoved) cacheSnapInfo(feat);
});
}
// ---- County road tag conversion (mirrors diff-highways.py logic) ----
function formatStreetName(s) {
if (!s) return s;
const abbrevs = {
'N':'North','NE':'Northeast','E':'East','SE':'Southeast','S':'South',
'SW':'Southwest','W':'West','NW':'Northwest',
'ST':'Street','AVE':'Avenue','AV':'Avenue','BLVD':'Boulevard','BV':'Boulevard',
'CIR':'Circle','CT':'Court','DR':'Drive','HWY':'Highway','HW':'Highway',
'LN':'Lane','LOOP':'Loop','LP':'Loop','PKWY':'Parkway','PL':'Place',
'PT':'Point','RD':'Road','TRL':'Trail','TR':'Trail','WAY':'Way','WY':'Way',
'XING':'Crossing','CRK':'Creek','RDG':'Ridge','RUN':'Run','GLN':'Glenn',
'GRV':'Grove','HL':'Hill','HTS':'Heights','MNR':'Manor','MT':'Mount',
'BND':'Bend','CRST':'Crest','CV':'Curve','CURV':'Curve','FLDS':'Fields',
'HOLW':'Hollow','LNDG':'Landing','MTN':'Mountain','PARK':'Park','PASS':'Pass',
'PATH':'Path','PLZ':'Plaza','SQ':'Square','TER':'Terrace','TRCE':'Terrace',
'TPKE':'Turnpike','VW':'View','WALK':'Walk','ALY':'Alley','BLF':'Bluff',
'COR':'Corner','SHRS':'Shores','SR':'SR','US':'US',
};
const parts = s.trim().split(/\s+/);
if (parts[0] && parts[0].toUpperCase() === 'ST' && parts.length > 1) parts[0] = 'Saint';
if (parts[0] && parts[0].toUpperCase() === 'CR') parts[0] = 'County Road';
return parts.map(p => {
const up = p.toUpperCase();
if (abbrevs[up]) return abbrevs[up];
if (/^[0-9]{2,4}[A-Za-z]$/.test(p)) return p;
return p.charAt(0).toUpperCase() + p.slice(1).toLowerCase();
}).join(' ');
}
function getLakeHighwayType(sc) {
return { ALLEY:'alley', LOCAL:'residential', MAJOR:'trunk', 'MEDIAN CUT':'primary_link',
OTHER:'unclassified', PRIMARY:'primary', PRIVATE:'service', RAMP:'trunk_link',
SECONDARY:'secondary', 'TURN LANE':'primary_link', 'VEHICULAR TRAIL':'track' }[sc] || 'residential';
}
function getSumterHighwayType(rc) {
if (!rc) return 'residential';
const v = String(rc);
if (v.startsWith('PRIMARY')) return 'trunk';
if (v.startsWith('MAJOR')) return 'primary';
return 'residential';
}
function countyPropsToOsmTags(props) {
const tags = { surface: 'asphalt' };
const isSumter = 'NAME' in props && 'RoadClass' in props;
const isLake = 'FullStreet' in props;
if (isSumter) {
if (props.NAME) tags.name = formatStreetName(props.NAME);
if (props.SpeedLimit != null) tags.maxspeed = `${props.SpeedLimit} mph`;
tags.highway = getSumterHighwayType(props.RoadClass);
if (props.OneWayCode === 'F') tags.oneway = 'yes';
else if (props.OneWayCode === 'T') tags.oneway = '-1';
const laneCount = parseInt(props.LANES);
if (laneCount > 0) tags.lanes = String(laneCount);
} else if (isLake) {
if (props.FullStreet) tags.name = formatStreetName(props.FullStreet);
if (props.SpeedLimit != null) tags.maxspeed = `${props.SpeedLimit} mph`;
tags.highway = getLakeHighwayType(props.StreetClas);
if (props.Oneway === 'FT') tags.oneway = 'yes';
else if (props.Oneway === 'TF') tags.oneway = '-1';
const laneCount = parseInt(props.NumberOfLa);
if (laneCount > 0) tags.lanes = String(laneCount);
} else {
const name = props.NAME || props.FullStreet || props.name;
if (name) tags.name = formatStreetName(name);
if (props.SpeedLimit != null) tags.maxspeed = `${props.SpeedLimit} mph`;
tags.highway = 'residential';
}
if (!tags.highway) tags.highway = 'residential';
return tags;
}
// ---- Snap preview markers ----
// Draw snap preview for one feature onto snapPreviewLayer.
// interactive=true also adds clickable endpoint dots (only used for the selected feature).
function drawSnapPreviewForFeature(feature, interactive) {
if (!feature || !feature.geometry || feature.geometry.type !== 'LineString') return;
const rawCoords = feature.geometry.coordinates;
const ends = [0, rawCoords.length - 1].map(i => {
const isStart = i === 0;
const snap = isStart ? feature._snapA : feature._snapB;
const enabledFlag = isStart ? '_snapAEnabled' : '_snapBEnabled';
const isEnabled = feature[enabledFlag] !== false;
const segSnap = isStart ? feature._segSnapA : feature._segSnapB;
const rawCoord = rawCoords[i];
let finalCoord = rawCoord;
if (isEnabled && snap) finalCoord = snap.coord;
else if (isEnabled && segSnap) finalCoord = segSnap.coord;
return { isStart, snap, segSnap, enabledFlag, isEnabled, rawCoord, finalCoord };
});
// Draw only the connector segment where an endpoint actually moves (raw → snapped).
// Don't redraw the full road — that duplicates what's already visible in the diff layer.
ends.forEach(({ rawCoord, finalCoord }) => {
if (Math.hypot(finalCoord[0] - rawCoord[0], finalCoord[1] - rawCoord[1]) < 1e-8) return;
L.polyline([[rawCoord[1], rawCoord[0]], [finalCoord[1], finalCoord[0]]], {
color: '#ff6b00', weight: 3, opacity: 0.85, dashArray: '7,5', pane: 'snapPane'
}).addTo(snapPreviewLayer);
});
if (!interactive) return;
ends.forEach(({ isStart, snap, segSnap, enabledFlag, isEnabled, finalCoord }) => {
let color, canToggle;
if (snap) {
color = isEnabled ? '#28a745' : '#dc3545'; canToggle = true;
} else if (segSnap) {
color = isEnabled ? '#007bff' : '#dc3545'; canToggle = true;
} else {
color = '#ffc107'; canToggle = false;
}
const marker = L.circleMarker([finalCoord[1], finalCoord[0]], {
radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.9, pane: 'snapPane'
});
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();
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
});
} else {
marker.bindTooltip(`${isStart ? 'Start' : 'End'}: dangling`, { sticky: true });
}
snapPreviewLayer.addLayer(marker);
});
}
// Redraw snap preview layer:
// - all accepted added features get a preview line when the checkbox is on
// - the currently selected diff added feature always gets the interactive dot markers
function updateSnapPreview() {
if (!snapPreviewLayer) return;
snapPreviewLayer.clearLayers();
if (osmVertices.length === 0) return;
const showAll = document.getElementById('showSnapPreview')?.checked;
const selectedSet = new Set(selectedFeatures);
if (showAll) {
acceptedFeatures.forEach(feat => {
if (!feat.geometry || feat.geometry.type !== 'LineString') return;
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
if (isRemoved || selectedSet.has(feat)) return;
drawSnapPreviewForFeature(feat, false);
});
}
// Selected diff added features always get interactive dots regardless of checkbox
selectedLayers.forEach(({ type }, i) => {
if (type !== 'diff') return;
const feat = selectedFeatures[i];
if (!feat || !feat.geometry || feat.geometry.type !== 'LineString') return;
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
if (!isRemoved) drawSnapPreviewForFeature(feat, true);
});
}
window.toggleSnapA = function(idx) {
idx = idx === undefined ? 0 : idx;
if (idx >= selectedFeatures.length) return;
selectedFeatures[idx]._snapAEnabled = selectedFeatures[idx]._snapAEnabled !== false ? false : true;
updateSnapPreview();
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
};
window.toggleSnapB = function(idx) {
idx = idx === undefined ? 0 : idx;
if (idx >= selectedFeatures.length) return;
selectedFeatures[idx]._snapBEnabled = selectedFeatures[idx]._snapBEnabled !== false ? false : true;
updateSnapPreview();
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
};
window.importCountyFeature = function() {
if (selectedFeatures.length !== 1 || selectedLayers[0].type !== 'county') return;
const source = selectedFeatures[0];
if (source.geometry.type !== 'LineString') return;
const osmTags = countyPropsToOsmTags(source.properties);
const synthetic = {
type: 'Feature',
geometry: JSON.parse(JSON.stringify(source.geometry)),
properties: osmTags,
_countySource: source
};
importedCountyFeatures.add(source);
acceptedFeatures.add(synthetic);
cacheSnapInfo(synthetic);
if (countyLayer) {
countyLayer.eachLayer(l => { if (l.feature === source) l.setStyle(countyStyle(source)); });
}
updateSnapPreview();
showStatus(`Imported "${osmTags.name || 'unnamed'}" from county data`, 'success');
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
};
function connectivitySection(feature, idx) {
if (idx === undefined) idx = 0;
if (!feature.geometry || feature.geometry.type !== 'LineString') return '';
if (osmVertices.length > 0 && (feature._snapA === undefined || feature._snapTol !== SNAP_TOL)) {
cacheSnapInfo(feature);
}
const coords = feature.geometry.coordinates;
const snapA = feature._snapA;
const snapB = feature._snapB;
const segA = feature._segSnapA;
const segB = feature._segSnapB;
// Check if an endpoint connects to another new diff road (shares coordinate within tolerance)
function findDiffConnection(coord) {
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)
? ` ${isEnabled ? 'Disable snap' : 'Enable snap'} `
: '';
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 = `
${lines.join(' ')}
`;
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();
});
document.getElementById('showSnapPreview').addEventListener('change', function() {
updateSnapPreview();
});
document.getElementById('snapDistance').addEventListener('change', function() {
const meters = Math.max(1, parseFloat(this.value) || 10);
this.value = meters;
SNAP_TOL = meters / 111320;
acceptedFeatures.forEach(feat => {
if (feat.geometry && feat.geometry.type === 'LineString') cacheSnapInfo(feat);
});
updateSnapPreview();
if (popupLatlng && selectedFeatures.length > 0) showMultiFeaturePopup(popupLatlng);
});
// Click on empty map area closes any open popup and clears selection
map.on('click', function(e) {
if (multiSelectMode) return;
if (e.originalEvent && e.originalEvent.shiftKey) return;
clearSelection();
});
// Load button
document.getElementById('loadButton').addEventListener('click', function() {
loadFiles();
// Auto-close controls on mobile after clicking load
if (window.innerWidth <= 768) {
setTimeout(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;
}
});