Add web frontend

This commit is contained in:
2025-12-02 12:34:40 -08:00
parent 86867c5c8d
commit 05fefc6ed0
2 changed files with 626 additions and 0 deletions
+454
View File
@@ -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 = '<div style="font-size: 12px;">';
html += `<div><strong>Status:</strong> ${isRemoved ? 'Removed' : 'Added/Modified'}</div>`;
if (props.name) {
html += `<div><strong>Name:</strong> ${props.name}</div>`;
}
if (props.highway) {
html += `<div><strong>Highway:</strong> ${props.highway}</div>`;
}
if (props.service) {
html += `<div><strong>Service:</strong> ${props.service}</div>`;
}
if (isAccepted) {
html += '<div style="margin-top: 8px; color: #007bff; font-weight: bold;">✓ Accepted</div>';
} else if (isRejected) {
html += '<div style="margin-top: 8px; color: #4a4a4a; font-weight: bold;">✗ Rejected</div>';
}
html += '<div style="margin-top: 10px; display: flex; gap: 5px;">';
html += '<button onclick="acceptFeature()" style="flex: 1; padding: 5px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">Accept</button>';
html += '<button onclick="rejectFeature()" style="flex: 1; padding: 5px; background: #6c757d; color: white; border: none; border-radius: 3px; cursor: pointer;">Reject</button>';
html += '</div>';
html += '</div>';
// 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);
});