// 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) // Drag selection state let isDragging = false; let dragStartPoint = null; let selectionBox = null; let multiSelectMode = false; // Mobile controls state let mobileControlsOpen = false; // 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); // 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 (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'; const hideService = document.getElementById('hideService').checked; if (isService && hideService) 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(); } // Filter function for diff features function shouldShowFeature(feature) { const props = feature.properties || {}; const isRemoved = props.removed === true || props.removed === 'True'; const isService = props.highway === 'service'; const showAdded = document.getElementById('showAdded').checked; const showRemoved = document.getElementById('showRemoved').checked; const hideService = document.getElementById('hideService').checked; // Check removed/added filter if (isRemoved && !showRemoved) return false; if (!isRemoved && !showAdded) return false; // Check service filter if (isService && hideService) 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'; const hideService = document.getElementById('hideService').checked; if (isService && hideService) 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); } } // Show popup for multiple features (JOSM-style aggregation) function showMultiFeaturePopup(latlng) { if (selectedFeatures.length === 0) return; // 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 }; 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)'}
`; } 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 += '
'; // 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); // 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'); } // Expose functions globally for onclick handlers window.acceptAllFeatures = acceptAllFeatures; window.rejectAllFeatures = rejectAllFeatures; // Update save button state function updateSaveButton() { document.getElementById('saveButton').disabled = acceptedFeatures.size === 0 && rejectedFeatures.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; // Build file paths based on county and data type let osmFile, diffFile, countyFile, diffAddedFile, diffRemovedFile; if (dataType === 'roads') { osmFile = `latest/${county}/osm-roads.geojson`; diffFile = `latest/${county}/diff-roads.geojson`; countyFile = `latest/${county}/county-roads.geojson`; } else if (dataType === 'paths') { osmFile = `latest/${county}/osm-paths.geojson`; diffFile = `latest/${county}/diff-paths.geojson`; countyFile = `latest/${county}/county-paths.geojson`; } else if (dataType === 'addresses') { osmFile = `latest/${county}/osm-addresses.geojson`; diffAddedFile = `latest/${county}/addresses-to-add.geojson`; diffRemovedFile = `latest/${county}/addresses-potentially-removed.geojson`; countyFile = `latest/${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(); 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); } } }); // Create and download added-approved.geojson if (acceptedAdded.length > 0) { const addedData = { type: 'FeatureCollection', features: acceptedAdded }; const addedStr = JSON.stringify(addedData, null, 2); const addedBlob = new Blob([addedStr], { type: 'application/json' }); const addedUrl = URL.createObjectURL(addedBlob); const addedLink = document.createElement('a'); addedLink.href = addedUrl; addedLink.download = 'added-approved.geojson'; document.body.appendChild(addedLink); addedLink.click(); document.body.removeChild(addedLink); URL.revokeObjectURL(addedUrl); } // Create and download removed-approved.geojson if (acceptedRemoved.length > 0) { const removedData = { type: 'FeatureCollection', features: acceptedRemoved }; const removedStr = JSON.stringify(removedData, null, 2); const removedBlob = new Blob([removedStr], { type: 'application/json' }); const removedUrl = URL.createObjectURL(removedBlob); const removedLink = document.createElement('a'); removedLink.href = removedUrl; removedLink.download = 'removed-approved.geojson'; document.body.appendChild(removedLink); removedLink.click(); document.body.removeChild(removedLink); URL.revokeObjectURL(removedUrl); } 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 closeButton = document.getElementById('closeButton'); 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); closeButton.addEventListener('click', closeControls); 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(); }); // 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); // 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'; } else { button.style.background = '#6c757d'; button.textContent = 'Multi-Select: OFF'; // 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; } });