From 05fefc6ed0907409254fcaefcc730c90c5f6f4a9 Mon Sep 17 00:00:00 2001 From: Will Bradley Date: Tue, 2 Dec 2025 12:34:40 -0800 Subject: [PATCH] Add web frontend --- web/app.js | 454 +++++++++++++++++++++++++++++++++++++++++++++++++ web/index.html | 172 +++++++++++++++++++ 2 files changed, 626 insertions(+) create mode 100644 web/app.js create mode 100644 web/index.html diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..941c812 --- /dev/null +++ b/web/app.js @@ -0,0 +1,454 @@ +// 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; + +// 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); +} + +// 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: '#8B4513', + 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: '#4a4a4a', + weight: 3, + opacity: 0.8 + }; + } + + const isRemoved = feature.properties && feature.properties.removed === true; + return { + color: isRemoved ? '#dc3545' : '#28a745', + weight: 3, + opacity: 0.8 + }; +} + +function countyStyle(feature) { + return { + color: '#800080', + weight: 3, + opacity: 0.7 + }; +} + +// Create layer for OSM data +function createOsmLayer() { + if (osmLayer) { + map.removeLayer(osmLayer); + } + + if (!osmData) return; + + osmLayer = L.geoJSON(osmData, { + style: osmStyle + }).addTo(map); +} + +// Create layer for diff data with click handlers +function createDiffLayer() { + if (diffLayer) { + map.removeLayer(diffLayer); + } + + if (!diffData) return; + + diffLayer = L.geoJSON(diffData, { + style: diffStyle, + onEachFeature: function(feature, layer) { + layer.on('click', function(e) { + L.DomEvent.stopPropagation(e); + selectFeature(feature, layer, e); + }); + + 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); +} + +// Create layer for county data +function createCountyLayer() { + if (countyLayer) { + map.removeLayer(countyLayer); + } + + if (!countyData) return; + + countyLayer = L.geoJSON(countyData, { + style: countyStyle + }); + + // County layer is hidden by default + if (document.getElementById('countyToggle').checked) { + countyLayer.addTo(map); + } +} + +// Select a feature from diff layer +function selectFeature(feature, layer, e) { + // Deselect previous feature + if (selectedLayer) { + selectedLayer.setStyle(diffStyle(selectedLayer.feature)); + } + + selectedFeature = feature; + selectedLayer = layer; + layer.setStyle({ + weight: 6, + opacity: 1, + color: '#ffc107' + }); + + // Create popup near the clicked location + const props = feature.properties || {}; + const isRemoved = props.removed === true; + const isAccepted = acceptedFeatures.has(feature); + const isRejected = rejectedFeatures.has(feature); + + let html = '
'; + html += `
Status: ${isRemoved ? 'Removed' : 'Added/Modified'}
`; + + if (props.name) { + html += `
Name: ${props.name}
`; + } + if (props.highway) { + html += `
Highway: ${props.highway}
`; + } + if (props.service) { + html += `
Service: ${props.service}
`; + } + + 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'); +} + +// Update save button state +function updateSaveButton() { + document.getElementById('saveButton').disabled = + acceptedFeatures.size === 0 && rejectedFeatures.size === 0; +} + +// Load file from input +function loadFile(input) { + return new Promise((resolve, reject) => { + const file = input.files[0]; + if (!file) { + resolve(null); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = JSON.parse(e.target.result); + resolve(data); + } catch (error) { + reject(new Error(`Failed to parse ${file.name}: ${error.message}`)); + } + }; + reader.onerror = () => reject(new Error(`Failed to read ${file.name}`)); + reader.readAsText(file); + }); +} + +// Load all files +async function loadFiles() { + try { + showStatus('Loading files...', 'success'); + + const osmInput = document.getElementById('osmFile'); + const diffInput = document.getElementById('diffFile'); + const countyInput = document.getElementById('countyFile'); + + // Load files + osmData = await loadFile(osmInput); + diffData = await loadFile(diffInput); + countyData = await loadFile(countyInput); + + if (!osmData && !diffData && !countyData) { + showStatus('Please select at least one file', 'error'); + return; + } + + // Create layers + createOsmLayer(); + createDiffLayer(); + createCountyLayer(); + + // Fit bounds to smallest layer + calculateBounds(); + + showStatus('Files loaded successfully!', '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); +} + +// 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); + } + } +} + +// 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); + }); + + // Load button + document.getElementById('loadButton').addEventListener('click', loadFiles); + + // Save button + document.getElementById('saveButton').addEventListener('click', saveAcceptedItems); +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b0f2d88 --- /dev/null +++ b/web/index.html @@ -0,0 +1,172 @@ + + + + + + GeoJSON Map Viewer + + + + +
+ +
+

Layer Controls

+ + + + +

Load Files

+
+ + +
+
+ + +
+
+ + +
+ + + + + +
+ + + + +