// Global state let map; let osmLayer; let diffLayer; let countyLayer; let osmData = null; let diffData = null; let countyData = null; let selectedFeature = null; let selectedLayer = null; let acceptedFeatures = new Set(); let rejectedFeatures = new Set(); let featurePopup = null; let layerOrder = ['diff', 'osm', 'county']; // Default layer order (top to bottom) // 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 map.getPane('osmPane').style.zIndex = 400; map.getPane('diffPane').style.zIndex = 401; map.getPane('countyPane').style.zIndex = 402; } // 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 function osmStyle(feature) { return { color: '#4a4a4a', weight: 3, opacity: 0.7 }; } 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 countyStyle(feature) { return { color: '#ff00ff', weight: 3, opacity: 0.8 }; } // 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', onEachFeature: function(feature, layer) { layer.on('click', function(e) { L.DomEvent.stopPropagation(e); selectFeature(feature, layer, e, 'osm'); }); layer.on('mouseover', function(e) { if (selectedLayer !== layer) { layer.setStyle({ weight: 5, opacity: 1 }); } }); layer.on('mouseout', function(e) { if (selectedLayer !== layer) { layer.setStyle(osmStyle(feature)); } }); } }).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', onEachFeature: function(feature, layer) { layer.on('click', function(e) { L.DomEvent.stopPropagation(e); selectFeature(feature, layer, e, 'diff'); }); layer.on('mouseover', function(e) { if (selectedLayer !== layer) { layer.setStyle({ weight: 5, opacity: 1 }); } }); layer.on('mouseout', function(e) { if (selectedLayer !== layer) { layer.setStyle(diffStyle(feature)); } }); } }).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', onEachFeature: function(feature, layer) { layer.on('click', function(e) { L.DomEvent.stopPropagation(e); selectFeature(feature, layer, e, 'county'); }); layer.on('mouseover', function(e) { if (selectedLayer !== layer) { layer.setStyle({ weight: 5, opacity: 1 }); } }); layer.on('mouseout', function(e) { if (selectedLayer !== layer) { 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') { // Deselect previous feature if (selectedLayer) { // Get the appropriate style function based on previous layer type const styleFunc = selectedLayer._layerType === 'diff' ? diffStyle : selectedLayer._layerType === 'osm' ? osmStyle : countyStyle; selectedLayer.setStyle(styleFunc(selectedLayer.feature)); } selectedFeature = feature; selectedLayer = layer; selectedLayer._layerType = layerType; // Store layer type for later layer.setStyle({ weight: 6, opacity: 1, color: '#ffc107' }); // Create popup near the clicked location const props = feature.properties || {}; const isRemoved = props.removed === true || props.removed === 'True'; const isAccepted = acceptedFeatures.has(feature); const isRejected = rejectedFeatures.has(feature); let html = '
'; // Show layer type html += `
Layer: ${layerType.toUpperCase()}
`; // Only show status for diff layer if (layerType === 'diff') { html += `
Status: ${isRemoved ? 'Removed' : 'Added/Modified'}
`; } // Display all non-null properties with custom ordering const displayProps = Object.entries(props) .filter(([key, value]) => value !== null && value !== undefined && key !== 'removed') .sort(([a], [b]) => { // Priority order: name, highway, then alphabetical const priorityOrder = { 'name': 0, 'highway': 1 }; const aPriority = priorityOrder[a] ?? 999; const bPriority = priorityOrder[b] ?? 999; if (aPriority !== bPriority) { return aPriority - bPriority; } return a.localeCompare(b); }); if (displayProps.length > 0) { html += '
'; for (const [key, value] of displayProps) { html += `
${key}: ${value}
`; } html += '
'; } // Only show accept/reject for diff layer if (layerType === 'diff') { if (isAccepted) { html += '
✓ Accepted
'; } else if (isRejected) { html += '
✗ Rejected
'; } html += '
'; html += ''; html += ''; html += '
'; } html += '
'; // Remove old popup if exists if (featurePopup) { map.closePopup(featurePopup); } // Create popup at click location featurePopup = L.popup({ maxWidth: 300, closeButton: true, autoClose: false, closeOnClick: false }) .setLatLng(e.latlng) .setContent(html) .openOn(map); // Handle popup close featurePopup.on('remove', function() { if (selectedLayer) { selectedLayer.setStyle(diffStyle(selectedLayer.feature)); selectedLayer = null; selectedFeature = null; } }); } // Accept a feature function acceptFeature() { if (!selectedFeature) return; // Remove from rejected if present rejectedFeatures.delete(selectedFeature); // Add to accepted acceptedFeatures.add(selectedFeature); // Update layer style if (selectedLayer) { selectedLayer.setStyle(diffStyle(selectedFeature)); } // Close popup if (featurePopup) { map.closePopup(featurePopup); } // Enable save button updateSaveButton(); showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success'); } // Reject a feature function rejectFeature() { if (!selectedFeature) return; // Remove from accepted if present acceptedFeatures.delete(selectedFeature); // Add to rejected rejectedFeatures.add(selectedFeature); // Update layer style if (selectedLayer) { selectedLayer.setStyle(diffStyle(selectedFeature)); } // Close popup if (featurePopup) { map.closePopup(featurePopup); } // Enable save button updateSaveButton(); showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success'); } // Expose functions globally for onclick handlers window.acceptFeature = acceptFeature; window.rejectFeature = rejectFeature; // 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; 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 = null; // No OSM addresses file diffFile = `latest/${county}/addresses-to-add.geojson`; countyFile = `latest/${county}/addresses-existing.geojson`; } // Load files from server osmData = osmFile ? await loadFromServer(`/data/${osmFile}`) : null; diffData = diffFile ? await loadFromServer(`/data/${diffFile}`) : null; countyData = countyFile ? await loadFromServer(`/data/${countyFile}`) : 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 and rejected items to original diff file async function saveAcceptedItems() { if (!diffData || (acceptedFeatures.size === 0 && rejectedFeatures.size === 0)) { showStatus('No features to save', 'error'); return; } try { // Add accepted=true or accepted=false property to features diffData.features.forEach(feature => { if (acceptedFeatures.has(feature)) { feature.properties.accepted = true; } else if (rejectedFeatures.has(feature)) { feature.properties.accepted = false; } }); // Create download const dataStr = JSON.stringify(diffData, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); link.href = url; link.download = 'diff-updated.geojson'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); showStatus(`Saved ${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, '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' }; // Reverse index so first item in list is on top layerOrder.forEach((layerName, index) => { const paneName = panes[layerName]; const pane = map.getPane(paneName); if (pane) { pane.style.zIndex = 400 + (layerOrder.length - 1 - index); } }); } // Toggle layer visibility function toggleLayer(layerId, layer) { const checkbox = document.getElementById(layerId); if (checkbox.checked && layer) { if (!map.hasLayer(layer)) { map.addLayer(layer); updateLayerZIndex(); } } else if (layer) { if (map.hasLayer(layer)) { map.removeLayer(layer); } } } // Event listeners document.addEventListener('DOMContentLoaded', function() { initMap(); // 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', loadFiles); // Save button document.getElementById('saveButton').addEventListener('click', saveAcceptedItems); // 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; } });