Add web frontend
This commit is contained in:
+454
@@ -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);
|
||||
});
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoJSON Map Viewer</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#map {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.controls h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.controls input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.controls button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.controls button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-input-group {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.file-input-group:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-input-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-input-group input[type="file"] {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.load-button {
|
||||
background: #28a745 !important;
|
||||
}
|
||||
|
||||
.load-button:hover {
|
||||
background: #218838 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
|
||||
<div class="controls">
|
||||
<h3>Layer Controls</h3>
|
||||
<label>
|
||||
<input type="checkbox" id="osmToggle" checked>
|
||||
OSM Roads (Brown)
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="diffToggle" checked>
|
||||
Diff Layer
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="countyToggle">
|
||||
County Layer (Purple)
|
||||
</label>
|
||||
|
||||
<h3 style="margin-top: 15px;">Load Files</h3>
|
||||
<div class="file-input-group">
|
||||
<label for="osmFile">OSM File:</label>
|
||||
<input type="file" id="osmFile" accept=".geojson,.json">
|
||||
</div>
|
||||
<div class="file-input-group">
|
||||
<label for="diffFile">Diff File:</label>
|
||||
<input type="file" id="diffFile" accept=".geojson,.json">
|
||||
</div>
|
||||
<div class="file-input-group">
|
||||
<label for="countyFile">County File:</label>
|
||||
<input type="file" id="countyFile" accept=".geojson,.json">
|
||||
</div>
|
||||
|
||||
<button id="loadButton" class="load-button">Load Files</button>
|
||||
<button id="saveButton" disabled>Save Accepted Items</button>
|
||||
|
||||
<div id="status" class="status hidden"></div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user