684 lines
20 KiB
JavaScript
684 lines
20 KiB
JavaScript
// 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 = '<div style="font-size: 12px; max-height: 400px; overflow-y: auto;">';
|
|
|
|
// Show layer type
|
|
html += `<div style="margin-bottom: 8px;"><strong>Layer:</strong> ${layerType.toUpperCase()}</div>`;
|
|
|
|
// Only show status for diff layer
|
|
if (layerType === 'diff') {
|
|
html += `<div style="margin-bottom: 8px;"><strong>Status:</strong> ${isRemoved ? 'Removed' : 'Added/Modified'}</div>`;
|
|
}
|
|
|
|
// 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 += '<div style="font-size: 11px;">';
|
|
for (const [key, value] of displayProps) {
|
|
html += `<div style="margin: 2px 0;"><strong>${key}:</strong> ${value}</div>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
// Only show accept/reject for diff layer
|
|
if (layerType === 'diff') {
|
|
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');
|
|
}
|
|
|
|
// 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 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);
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
});
|