1316 lines
44 KiB
JavaScript
1316 lines
44 KiB
JavaScript
// Global state
|
|
let map;
|
|
let osmLayer;
|
|
let diffLayer;
|
|
let countyLayer;
|
|
let osmData = null;
|
|
let diffData = null;
|
|
let countyData = null;
|
|
let selectedFeatures = [];
|
|
let selectedLayers = [];
|
|
let acceptedFeatures = new Set();
|
|
let rejectedFeatures = new Set();
|
|
let featurePopup = null;
|
|
let layerOrder = ['diff', 'osm', 'county']; // Default layer order (top to bottom)
|
|
|
|
// Drag selection state
|
|
let isDragging = false;
|
|
let dragStartPoint = null;
|
|
let selectionBox = null;
|
|
let multiSelectMode = false;
|
|
|
|
// Mobile controls state
|
|
let mobileControlsOpen = false;
|
|
let loadedCounty = '';
|
|
let loadedDataType = '';
|
|
|
|
// 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 based on layerOrder
|
|
updateLayerZIndex();
|
|
|
|
// Setup drag selection
|
|
setupDragSelection();
|
|
}
|
|
|
|
// Setup drag selection functionality
|
|
function setupDragSelection() {
|
|
const mapContainer = map.getContainer();
|
|
|
|
map.on('mousedown', function(e) {
|
|
// Only start drag selection with shift key or multi-select mode enabled
|
|
if (!e.originalEvent.shiftKey && !multiSelectMode) return;
|
|
|
|
isDragging = true;
|
|
dragStartPoint = e.containerPoint;
|
|
|
|
// Create selection box
|
|
selectionBox = L.DomUtil.create('div', 'selection-box', mapContainer);
|
|
selectionBox.style.left = dragStartPoint.x + 'px';
|
|
selectionBox.style.top = dragStartPoint.y + 'px';
|
|
|
|
// Prevent map panning while dragging
|
|
map.dragging.disable();
|
|
|
|
e.originalEvent.preventDefault();
|
|
});
|
|
|
|
map.on('mousemove', function(e) {
|
|
if (!isDragging || !selectionBox) return;
|
|
|
|
const currentPoint = e.containerPoint;
|
|
const minX = Math.min(dragStartPoint.x, currentPoint.x);
|
|
const minY = Math.min(dragStartPoint.y, currentPoint.y);
|
|
const width = Math.abs(currentPoint.x - dragStartPoint.x);
|
|
const height = Math.abs(currentPoint.y - dragStartPoint.y);
|
|
|
|
selectionBox.style.left = minX + 'px';
|
|
selectionBox.style.top = minY + 'px';
|
|
selectionBox.style.width = width + 'px';
|
|
selectionBox.style.height = height + 'px';
|
|
});
|
|
|
|
map.on('mouseup', function(e) {
|
|
if (!isDragging) return;
|
|
|
|
isDragging = false;
|
|
map.dragging.enable();
|
|
|
|
if (selectionBox) {
|
|
const endPoint = e.containerPoint;
|
|
|
|
// Calculate bounds
|
|
const minX = Math.min(dragStartPoint.x, endPoint.x);
|
|
const minY = Math.min(dragStartPoint.y, endPoint.y);
|
|
const maxX = Math.max(dragStartPoint.x, endPoint.x);
|
|
const maxY = Math.max(dragStartPoint.y, endPoint.y);
|
|
|
|
const bounds = L.latLngBounds(
|
|
map.containerPointToLatLng([minX, minY]),
|
|
map.containerPointToLatLng([maxX, maxY])
|
|
);
|
|
|
|
// Select features within bounds
|
|
selectFeaturesInBounds(bounds);
|
|
|
|
// Remove selection box
|
|
mapContainer.removeChild(selectionBox);
|
|
selectionBox = null;
|
|
}
|
|
|
|
dragStartPoint = null;
|
|
});
|
|
}
|
|
|
|
// Select features within bounds
|
|
function selectFeaturesInBounds(bounds) {
|
|
clearSelection();
|
|
|
|
// Only select from diff layer (OSM/county items can't be accepted/rejected)
|
|
const layers = [
|
|
{ layer: diffLayer, data: diffData, type: 'diff' }
|
|
];
|
|
|
|
layers.forEach(({ layer, data, type }) => {
|
|
if (!layer || !data) return;
|
|
|
|
layer.eachLayer(function(leafletLayer) {
|
|
const feature = leafletLayer.feature;
|
|
if (!feature) return;
|
|
|
|
let isInBounds = false;
|
|
|
|
// Check if feature is within bounds
|
|
if (feature.geometry.type === 'Point') {
|
|
const coords = feature.geometry.coordinates;
|
|
const latlng = L.latLng(coords[1], coords[0]);
|
|
isInBounds = bounds.contains(latlng);
|
|
} else if (feature.geometry.type === 'LineString') {
|
|
// Check if any point of the linestring is within bounds
|
|
const coords = feature.geometry.coordinates;
|
|
isInBounds = coords.some(coord => {
|
|
const latlng = L.latLng(coord[1], coord[0]);
|
|
return bounds.contains(latlng);
|
|
});
|
|
}
|
|
|
|
if (isInBounds) {
|
|
selectedFeatures.push(feature);
|
|
selectedLayers.push({ layer: leafletLayer, type: type });
|
|
|
|
// Highlight selected feature
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
if (isPoint) {
|
|
leafletLayer.setStyle({
|
|
radius: 9,
|
|
fillColor: '#ffc107',
|
|
color: '#ff9800',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1
|
|
});
|
|
} else {
|
|
leafletLayer.setStyle({
|
|
weight: 6,
|
|
opacity: 1,
|
|
color: '#ffc107'
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
if (selectedFeatures.length > 0) {
|
|
// Calculate center point for popup
|
|
const center = bounds.getCenter();
|
|
showMultiFeaturePopup(center);
|
|
}
|
|
}
|
|
|
|
// Clear current selection
|
|
function clearSelection() {
|
|
// Restore original styles for previously selected features
|
|
selectedLayers.forEach(({ layer, type }) => {
|
|
const feature = layer.feature;
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
|
|
if (isPoint) {
|
|
const markerStyleFunc = type === 'diff' ? diffMarkerStyle :
|
|
type === 'osm' ? osmMarkerStyle : countyMarkerStyle;
|
|
layer.setStyle(markerStyleFunc(feature));
|
|
} else {
|
|
const styleFunc = type === 'diff' ? diffStyle :
|
|
type === 'osm' ? osmStyle : countyStyle;
|
|
layer.setStyle(styleFunc(feature));
|
|
}
|
|
});
|
|
|
|
selectedFeatures = [];
|
|
selectedLayers = [];
|
|
|
|
if (featurePopup) {
|
|
map.closePopup(featurePopup);
|
|
featurePopup = null;
|
|
}
|
|
}
|
|
|
|
// 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 for lines
|
|
function osmStyle(feature) {
|
|
return {
|
|
color: '#4a4a4a',
|
|
weight: 3,
|
|
opacity: 0.7
|
|
};
|
|
}
|
|
|
|
// Style functions for point markers (addresses)
|
|
function osmMarkerStyle(feature) {
|
|
return {
|
|
radius: 6,
|
|
fillColor: '#4a4a4a',
|
|
color: '#333',
|
|
weight: 1,
|
|
opacity: 0.8,
|
|
fillOpacity: 0.6
|
|
};
|
|
}
|
|
|
|
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 diffMarkerStyle(feature) {
|
|
// Check if feature is accepted or rejected
|
|
if (acceptedFeatures.has(feature)) {
|
|
return {
|
|
radius: 6,
|
|
fillColor: '#007bff',
|
|
color: '#0056b3',
|
|
weight: 1,
|
|
opacity: 0.8,
|
|
fillOpacity: 0.7
|
|
};
|
|
}
|
|
|
|
if (rejectedFeatures.has(feature)) {
|
|
return {
|
|
radius: 6,
|
|
fillColor: '#ff8c00',
|
|
color: '#d47200',
|
|
weight: 1,
|
|
opacity: 0.8,
|
|
fillOpacity: 0.7
|
|
};
|
|
}
|
|
|
|
const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True');
|
|
return {
|
|
radius: 6,
|
|
fillColor: isRemoved ? '#ff0000' : '#00ff00',
|
|
color: isRemoved ? '#cc0000' : '#00cc00',
|
|
weight: 1,
|
|
opacity: 0.8,
|
|
fillOpacity: 0.7
|
|
};
|
|
}
|
|
|
|
function countyStyle(feature) {
|
|
return {
|
|
color: '#ff00ff',
|
|
weight: 3,
|
|
opacity: 0.8
|
|
};
|
|
}
|
|
|
|
function countyMarkerStyle(feature) {
|
|
return {
|
|
radius: 6,
|
|
fillColor: '#ff00ff',
|
|
color: '#cc00cc',
|
|
weight: 1,
|
|
opacity: 0.8,
|
|
fillOpacity: 0.6
|
|
};
|
|
}
|
|
|
|
// Filter function for OSM features
|
|
function shouldShowOsmFeature(feature) {
|
|
const props = feature.properties || {};
|
|
const isService = props.highway === 'service' || props.highway === 'track';
|
|
const isUnclassified = props.highway === 'unclassified';
|
|
const hideService = document.getElementById('hideService').checked;
|
|
const hideUnclassified = document.getElementById('hideUnclassified').checked;
|
|
|
|
if (isService && hideService) return false;
|
|
if (isUnclassified && hideUnclassified) 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',
|
|
pointToLayer: function(feature, latlng) {
|
|
return L.circleMarker(latlng, osmMarkerStyle(feature));
|
|
},
|
|
onEachFeature: function(feature, layer) {
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
|
|
layer.on('click', function(e) {
|
|
L.DomEvent.stopPropagation(e);
|
|
selectFeature(feature, layer, e, 'osm');
|
|
});
|
|
|
|
layer.on('mouseover', function(e) {
|
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
|
if (!isSelected) {
|
|
if (isPoint) {
|
|
layer.setStyle({
|
|
radius: 8,
|
|
fillOpacity: 1
|
|
});
|
|
} else {
|
|
layer.setStyle({
|
|
weight: 5,
|
|
opacity: 1
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
layer.on('mouseout', function(e) {
|
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
|
if (!isSelected) {
|
|
if (isPoint) {
|
|
layer.setStyle(osmMarkerStyle(feature));
|
|
} else {
|
|
layer.setStyle(osmStyle(feature));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Only add to map if checkbox is checked
|
|
if (document.getElementById('osmToggle').checked) {
|
|
osmLayer.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' || props.highway === 'track';
|
|
const isUnclassified = props.highway === 'unclassified';
|
|
|
|
const showAdded = document.getElementById('showAdded').checked;
|
|
const showRemoved = document.getElementById('showRemoved').checked;
|
|
const hideService = document.getElementById('hideService').checked;
|
|
const hideUnclassified = document.getElementById('hideUnclassified').checked;
|
|
|
|
// Check removed/added filter
|
|
if (isRemoved && !showRemoved) return false;
|
|
if (!isRemoved && !showAdded) return false;
|
|
|
|
// Check service/track and unclassified filters
|
|
if (isService && hideService) return false;
|
|
if (isUnclassified && hideUnclassified) 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',
|
|
pointToLayer: function(feature, latlng) {
|
|
return L.circleMarker(latlng, diffMarkerStyle(feature));
|
|
},
|
|
onEachFeature: function(feature, layer) {
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
|
|
layer.on('click', function(e) {
|
|
L.DomEvent.stopPropagation(e);
|
|
selectFeature(feature, layer, e, 'diff');
|
|
});
|
|
|
|
layer.on('mouseover', function(e) {
|
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
|
if (!isSelected) {
|
|
if (isPoint) {
|
|
layer.setStyle({
|
|
radius: 8,
|
|
fillOpacity: 1
|
|
});
|
|
} else {
|
|
layer.setStyle({
|
|
weight: 5,
|
|
opacity: 1
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
layer.on('mouseout', function(e) {
|
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
|
if (!isSelected) {
|
|
if (isPoint) {
|
|
layer.setStyle(diffMarkerStyle(feature));
|
|
} else {
|
|
layer.setStyle(diffStyle(feature));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Only add to map if checkbox is checked
|
|
if (document.getElementById('diffToggle').checked) {
|
|
diffLayer.addTo(map);
|
|
}
|
|
|
|
updateLayerZIndex();
|
|
}
|
|
|
|
// Filter function for county features
|
|
function shouldShowCountyFeature(feature) {
|
|
const props = feature.properties || {};
|
|
const isService = props.highway === 'service' || props.highway === 'track';
|
|
const isUnclassified = props.highway === 'unclassified';
|
|
const hideService = document.getElementById('hideService').checked;
|
|
const hideUnclassified = document.getElementById('hideUnclassified').checked;
|
|
|
|
if (isService && hideService) return false;
|
|
if (isUnclassified && hideUnclassified) 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',
|
|
pointToLayer: function(feature, latlng) {
|
|
return L.circleMarker(latlng, countyMarkerStyle(feature));
|
|
},
|
|
onEachFeature: function(feature, layer) {
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
|
|
layer.on('click', function(e) {
|
|
L.DomEvent.stopPropagation(e);
|
|
selectFeature(feature, layer, e, 'county');
|
|
});
|
|
|
|
layer.on('mouseover', function(e) {
|
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
|
if (!isSelected) {
|
|
if (isPoint) {
|
|
layer.setStyle({
|
|
radius: 8,
|
|
fillOpacity: 1
|
|
});
|
|
} else {
|
|
layer.setStyle({
|
|
weight: 5,
|
|
opacity: 1
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
layer.on('mouseout', function(e) {
|
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
|
if (!isSelected) {
|
|
if (isPoint) {
|
|
layer.setStyle(countyMarkerStyle(feature));
|
|
} else {
|
|
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') {
|
|
// Check if this is a multi-select attempt
|
|
const isMultiSelect = (e.originalEvent && e.originalEvent.shiftKey) || multiSelectMode;
|
|
|
|
// Only allow multi-selection of diff layer features (OSM/county can't be accepted/rejected)
|
|
if (layerType !== 'diff' && isMultiSelect) {
|
|
return; // Block multi-selection of OSM/county layers
|
|
}
|
|
if (isMultiSelect) {
|
|
// Check if already selected
|
|
const alreadySelected = selectedFeatures.includes(feature);
|
|
if (alreadySelected) {
|
|
// Remove from selection
|
|
const index = selectedFeatures.indexOf(feature);
|
|
selectedFeatures.splice(index, 1);
|
|
const layerInfo = selectedLayers[index];
|
|
selectedLayers.splice(index, 1);
|
|
|
|
// Restore original style
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
if (isPoint) {
|
|
const markerStyleFunc = layerType === 'diff' ? diffMarkerStyle :
|
|
layerType === 'osm' ? osmMarkerStyle : countyMarkerStyle;
|
|
layer.setStyle(markerStyleFunc(feature));
|
|
} else {
|
|
const styleFunc = layerType === 'diff' ? diffStyle :
|
|
layerType === 'osm' ? osmStyle : countyStyle;
|
|
layer.setStyle(styleFunc(feature));
|
|
}
|
|
} else {
|
|
// Add to selection
|
|
selectedFeatures.push(feature);
|
|
selectedLayers.push({ layer: layer, type: layerType });
|
|
|
|
// Highlight
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
if (isPoint) {
|
|
layer.setStyle({
|
|
radius: 9,
|
|
fillColor: '#ffc107',
|
|
color: '#ff9800',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1
|
|
});
|
|
} else {
|
|
layer.setStyle({
|
|
weight: 6,
|
|
opacity: 1,
|
|
color: '#ffc107'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update popup with current selection
|
|
if (selectedFeatures.length > 0) {
|
|
showMultiFeaturePopup(e.latlng);
|
|
} else {
|
|
clearSelection();
|
|
}
|
|
} else {
|
|
// Regular click - replace selection
|
|
clearSelection();
|
|
|
|
selectedFeatures = [feature];
|
|
selectedLayers = [{ layer: layer, type: layerType }];
|
|
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
if (isPoint) {
|
|
layer.setStyle({
|
|
radius: 9,
|
|
fillColor: '#ffc107',
|
|
color: '#ff9800',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1
|
|
});
|
|
} else {
|
|
layer.setStyle({
|
|
weight: 6,
|
|
opacity: 1,
|
|
color: '#ffc107'
|
|
});
|
|
}
|
|
|
|
// Show popup for single or multiple features
|
|
showMultiFeaturePopup(e.latlng);
|
|
}
|
|
}
|
|
|
|
// Show popup for multiple features (JOSM-style aggregation)
|
|
function showMultiFeaturePopup(latlng) {
|
|
if (selectedFeatures.length === 0) return;
|
|
|
|
// Remove old popup if exists
|
|
if (featurePopup) {
|
|
map.closePopup(featurePopup);
|
|
}
|
|
|
|
let html = '<div style="font-size: 12px; max-height: 400px; overflow-y: auto;">';
|
|
|
|
// Show count
|
|
html += `<div style="margin-bottom: 8px;"><strong>Selected:</strong> ${selectedFeatures.length} feature${selectedFeatures.length > 1 ? 's' : ''}</div>`;
|
|
|
|
// Aggregate properties JOSM-style
|
|
const allKeys = new Set();
|
|
selectedFeatures.forEach(feature => {
|
|
Object.keys(feature.properties || {}).forEach(key => {
|
|
if (key !== 'removed') allKeys.add(key);
|
|
});
|
|
});
|
|
|
|
// Priority order for display
|
|
const priorityOrder = {
|
|
'addr:housenumber': 0,
|
|
'addr:street': 1,
|
|
'addr:unit': 2,
|
|
'addr:city': 3,
|
|
'addr:postcode': 4,
|
|
'addr:state': 5,
|
|
'name': 10,
|
|
'highway': 11,
|
|
// County road fields
|
|
'Lifecycle': 20,
|
|
'LIFECYCLE': 20,
|
|
'FullStreetN': 21,
|
|
'FullName': 21,
|
|
'StreetClass': 22,
|
|
'STRCLASS': 22,
|
|
'SpeedLimit': 23,
|
|
'SPEEDLIMIT': 23,
|
|
'LeftCity': 24,
|
|
'RightCity': 25,
|
|
'LeftZip': 26,
|
|
'RightZip': 27,
|
|
};
|
|
|
|
const sortedKeys = Array.from(allKeys).sort((a, b) => {
|
|
const aPriority = priorityOrder[a] ?? 999;
|
|
const bPriority = priorityOrder[b] ?? 999;
|
|
if (aPriority !== bPriority) {
|
|
return aPriority - bPriority;
|
|
}
|
|
return a.localeCompare(b);
|
|
});
|
|
|
|
// For each property, check if all values are the same
|
|
html += '<div style="font-size: 11px;">';
|
|
for (const key of sortedKeys) {
|
|
const values = selectedFeatures
|
|
.map(f => f.properties[key])
|
|
.filter(v => v !== null && v !== undefined);
|
|
|
|
if (values.length === 0) continue;
|
|
|
|
const uniqueValues = [...new Set(values.map(v => String(v)))];
|
|
|
|
let displayValue;
|
|
if (uniqueValues.length === 1) {
|
|
// All values are the same
|
|
displayValue = uniqueValues[0];
|
|
} else {
|
|
// Different values
|
|
displayValue = `<${uniqueValues.length} different values>`;
|
|
}
|
|
|
|
html += `<div style="margin: 2px 0;"><strong>${key}:</strong> ${displayValue}</div>`;
|
|
}
|
|
html += '</div>';
|
|
|
|
// Show layer types if mixed
|
|
const layerTypes = selectedLayers.map(l => l.type);
|
|
const uniqueLayerTypes = [...new Set(layerTypes)];
|
|
if (uniqueLayerTypes.length > 1) {
|
|
html += `<div style="margin-top: 8px;"><strong>Layers:</strong> ${uniqueLayerTypes.join(', ')}</div>`;
|
|
} else {
|
|
html += `<div style="margin-top: 8px;"><strong>Layer:</strong> ${uniqueLayerTypes[0].toUpperCase()}</div>`;
|
|
}
|
|
|
|
// Show status (Added/Removed) for diff layer features
|
|
const hasDiffFeatures = selectedLayers.some(l => l.type === 'diff');
|
|
if (hasDiffFeatures) {
|
|
const addedCount = selectedFeatures.filter(f => !f.properties || f.properties.removed !== true && f.properties.removed !== 'True').length;
|
|
const removedCount = selectedFeatures.filter(f => f.properties && (f.properties.removed === true || f.properties.removed === 'True')).length;
|
|
|
|
if (selectedFeatures.length === 1) {
|
|
// Single selection - show specific status
|
|
const isRemoved = selectedFeatures[0].properties && (selectedFeatures[0].properties.removed === true || selectedFeatures[0].properties.removed === 'True');
|
|
html += `<div style="margin-top: 8px;"><strong>Status:</strong> ${isRemoved ? 'Removed (Red)' : 'Added (Green)'}</div>`;
|
|
} else if (addedCount > 0 && removedCount > 0) {
|
|
// Mixed selection
|
|
html += `<div style="margin-top: 8px;"><strong>Status:</strong> ${addedCount} added, ${removedCount} removed</div>`;
|
|
} else if (addedCount > 0) {
|
|
html += `<div style="margin-top: 8px;"><strong>Status:</strong> All Added (Green)</div>`;
|
|
} else if (removedCount > 0) {
|
|
html += `<div style="margin-top: 8px;"><strong>Status:</strong> All Removed (Red)</div>`;
|
|
}
|
|
}
|
|
|
|
// Show accept/reject status if any are from diff layer
|
|
if (hasDiffFeatures) {
|
|
const acceptedCount = selectedFeatures.filter(f => acceptedFeatures.has(f)).length;
|
|
const rejectedCount = selectedFeatures.filter(f => rejectedFeatures.has(f)).length;
|
|
|
|
if (acceptedCount > 0 || rejectedCount > 0) {
|
|
html += '<div style="margin-top: 8px; font-size: 11px;">';
|
|
if (acceptedCount > 0) html += `<div style="color: #007bff;">✓ ${acceptedCount} accepted</div>`;
|
|
if (rejectedCount > 0) html += `<div style="color: #4a4a4a;">✗ ${rejectedCount} rejected</div>`;
|
|
html += '</div>';
|
|
}
|
|
|
|
// Show accept/reject buttons only if all selected features are from diff layer
|
|
const allDiff = selectedLayers.every(l => l.type === 'diff');
|
|
if (allDiff) {
|
|
const buttonText = selectedFeatures.length === 1 ? '' : ' All';
|
|
html += '<div style="margin-top: 10px; display: flex; gap: 5px;">';
|
|
html += `<button onclick="acceptAllFeatures()" style="flex: 1; padding: 5px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">Accept${buttonText}</button>`;
|
|
html += `<button onclick="rejectAllFeatures()" style="flex: 1; padding: 5px; background: #6c757d; color: white; border: none; border-radius: 3px; cursor: pointer;">Reject${buttonText}</button>`;
|
|
html += '</div>';
|
|
}
|
|
}
|
|
|
|
html += '</div>';
|
|
|
|
// Create popup at click location
|
|
featurePopup = L.popup({
|
|
maxWidth: 300,
|
|
closeButton: true,
|
|
autoClose: false,
|
|
closeOnClick: false
|
|
})
|
|
.setLatLng(latlng)
|
|
.setContent(html)
|
|
.openOn(map);
|
|
|
|
// Handle popup close
|
|
featurePopup.on('remove', function() {
|
|
clearSelection();
|
|
});
|
|
}
|
|
|
|
// Accept all selected features
|
|
function acceptAllFeatures() {
|
|
if (selectedFeatures.length === 0) return;
|
|
|
|
selectedFeatures.forEach((feature, index) => {
|
|
// Remove from rejected if present
|
|
rejectedFeatures.delete(feature);
|
|
|
|
// Add to accepted
|
|
acceptedFeatures.add(feature);
|
|
|
|
// Update layer style
|
|
const layerInfo = selectedLayers[index];
|
|
if (layerInfo && layerInfo.layer) {
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
if (isPoint) {
|
|
layerInfo.layer.setStyle(diffMarkerStyle(feature));
|
|
} else {
|
|
layerInfo.layer.setStyle(diffStyle(feature));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Close popup
|
|
if (featurePopup) {
|
|
map.closePopup(featurePopup);
|
|
}
|
|
|
|
// Clear selection
|
|
clearSelection();
|
|
|
|
// Enable save button
|
|
updateSaveButton();
|
|
|
|
showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
|
|
}
|
|
|
|
// Reject all selected features
|
|
function rejectAllFeatures() {
|
|
if (selectedFeatures.length === 0) return;
|
|
|
|
selectedFeatures.forEach((feature, index) => {
|
|
// Remove from accepted if present
|
|
acceptedFeatures.delete(feature);
|
|
|
|
// Add to rejected
|
|
rejectedFeatures.add(feature);
|
|
|
|
// Update layer style
|
|
const layerInfo = selectedLayers[index];
|
|
if (layerInfo && layerInfo.layer) {
|
|
const isPoint = feature.geometry.type === 'Point';
|
|
if (isPoint) {
|
|
layerInfo.layer.setStyle(diffMarkerStyle(feature));
|
|
} else {
|
|
layerInfo.layer.setStyle(diffStyle(feature));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Close popup
|
|
if (featurePopup) {
|
|
map.closePopup(featurePopup);
|
|
}
|
|
|
|
// Clear selection
|
|
clearSelection();
|
|
|
|
// Enable save button
|
|
updateSaveButton();
|
|
|
|
showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
|
|
}
|
|
|
|
// Expose functions globally for onclick handlers
|
|
window.acceptAllFeatures = acceptAllFeatures;
|
|
window.rejectAllFeatures = rejectAllFeatures;
|
|
|
|
// 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;
|
|
loadedCounty = county;
|
|
loadedDataType = dataType;
|
|
|
|
// Build file paths based on county and data type
|
|
let osmFile, diffFile, countyFile, diffAddedFile, diffRemovedFile;
|
|
|
|
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 = `latest/${county}/osm-addresses.geojson`;
|
|
diffAddedFile = `latest/${county}/addresses-to-add.geojson`;
|
|
diffRemovedFile = `latest/${county}/addresses-potentially-removed.geojson`;
|
|
countyFile = `latest/${county}/addresses.shp_converted.geojson`;
|
|
}
|
|
|
|
// Load files from server
|
|
osmData = osmFile ? await loadFromServer(`/data/${osmFile}`) : null;
|
|
countyData = countyFile ? await loadFromServer(`/data/${countyFile}`) : null;
|
|
|
|
// For addresses, load both added and removed files and combine them
|
|
if (dataType === 'addresses' && (diffAddedFile || diffRemovedFile)) {
|
|
const addedData = diffAddedFile ? await loadFromServer(`/data/${diffAddedFile}`) : null;
|
|
const removedData = diffRemovedFile ? await loadFromServer(`/data/${diffRemovedFile}`) : null;
|
|
|
|
// Combine both datasets into diffData
|
|
diffData = {
|
|
type: 'FeatureCollection',
|
|
features: []
|
|
};
|
|
|
|
// Add "removed" features FIRST (red) - they will render beneath
|
|
if (removedData && removedData.features) {
|
|
removedData.features.forEach(feature => {
|
|
if (!feature.properties) feature.properties = {};
|
|
feature.properties.removed = true;
|
|
diffData.features.push(feature);
|
|
});
|
|
}
|
|
|
|
// Add "added" features SECOND (green) - they will render on top
|
|
if (addedData && addedData.features) {
|
|
addedData.features.forEach(feature => {
|
|
// Ensure removed property is not set or is false
|
|
if (!feature.properties) feature.properties = {};
|
|
feature.properties.removed = false;
|
|
diffData.features.push(feature);
|
|
});
|
|
}
|
|
|
|
// If no features loaded, set to null
|
|
if (diffData.features.length === 0) {
|
|
diffData = null;
|
|
}
|
|
} else {
|
|
// For roads/paths, load single diff file
|
|
diffData = diffFile ? await loadFromServer(`/data/${diffFile}`) : 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 items to separate files (added-approved.geojson and removed-approved.geojson)
|
|
async function saveAcceptedItems() {
|
|
if (!diffData || acceptedFeatures.size === 0) {
|
|
showStatus('No accepted features to save', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Separate accepted features into added and removed
|
|
const acceptedAdded = [];
|
|
const acceptedRemoved = [];
|
|
|
|
diffData.features.forEach(feature => {
|
|
if (acceptedFeatures.has(feature)) {
|
|
// Clone feature and remove status/approved/removed properties
|
|
const cleanFeature = {
|
|
type: feature.type,
|
|
geometry: feature.geometry,
|
|
properties: {}
|
|
};
|
|
|
|
// Copy all properties except status, accepted, and removed
|
|
Object.keys(feature.properties).forEach(key => {
|
|
if (key !== 'status' && key !== 'accepted' && key !== 'removed') {
|
|
cleanFeature.properties[key] = feature.properties[key];
|
|
}
|
|
});
|
|
|
|
// Determine if added or removed
|
|
const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True');
|
|
if (isRemoved) {
|
|
acceptedRemoved.push(cleanFeature);
|
|
} else {
|
|
acceptedAdded.push(cleanFeature);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Get the date of the loaded data from the server
|
|
let dataDate = '';
|
|
try {
|
|
const dateResp = await fetch('/api/latest-date');
|
|
if (dateResp.ok) dataDate = (await dateResp.json()).date || '';
|
|
} catch (_) {}
|
|
const prefix = [loadedCounty, loadedDataType, dataDate].filter(Boolean).join('-');
|
|
|
|
function triggerDownload(features, label) {
|
|
const blob = new Blob([JSON.stringify({ type: 'FeatureCollection', features }, null, 2)],
|
|
{ type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${prefix}-${label}.geojson`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
if (acceptedAdded.length > 0) triggerDownload(acceptedAdded, 'added-approved');
|
|
if (acceptedRemoved.length > 0) triggerDownload(acceptedRemoved, 'removed-approved');
|
|
|
|
showStatus(`Saved ${acceptedAdded.length} added, ${acceptedRemoved.length} removed (approved only)`, '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'
|
|
};
|
|
|
|
const layers = {
|
|
'osm': osmLayer,
|
|
'diff': diffLayer,
|
|
'county': countyLayer
|
|
};
|
|
|
|
// Reverse index so first item in list is on top
|
|
// Use higher base z-index (650) to ensure we're above overlay pane
|
|
layerOrder.forEach((layerName, index) => {
|
|
const paneName = panes[layerName];
|
|
const pane = map.getPane(paneName);
|
|
if (pane) {
|
|
const zIndex = 650 + (layerOrder.length - 1 - index);
|
|
pane.style.zIndex = zIndex;
|
|
}
|
|
});
|
|
|
|
// Also bring layers to front in reverse order (last first, so first ends up on top)
|
|
for (let i = layerOrder.length - 1; i >= 0; i--) {
|
|
const layer = layers[layerOrder[i]];
|
|
if (layer && map.hasLayer(layer)) {
|
|
layer.bringToFront();
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// Always update z-index after toggling any layer
|
|
updateLayerZIndex();
|
|
}
|
|
|
|
// Event listeners
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initMap();
|
|
|
|
// Mobile hamburger menu toggle
|
|
const hamburgerButton = document.getElementById('hamburgerButton');
|
|
const closeButton = document.getElementById('closeButton');
|
|
const controls = document.getElementById('controls');
|
|
const overlay = document.getElementById('overlay');
|
|
|
|
function toggleControls() {
|
|
mobileControlsOpen = !mobileControlsOpen;
|
|
controls.classList.toggle('open');
|
|
hamburgerButton.classList.toggle('active');
|
|
overlay.classList.toggle('active');
|
|
}
|
|
|
|
function closeControls() {
|
|
mobileControlsOpen = false;
|
|
controls.classList.remove('open');
|
|
hamburgerButton.classList.remove('active');
|
|
overlay.classList.remove('active');
|
|
}
|
|
|
|
// Expose closeControls globally for other functions
|
|
window.closeMobileControls = closeControls;
|
|
|
|
hamburgerButton.addEventListener('click', toggleControls);
|
|
closeButton.addEventListener('click', closeControls);
|
|
overlay.addEventListener('click', closeControls);
|
|
|
|
// 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();
|
|
});
|
|
|
|
document.getElementById('hideUnclassified').addEventListener('change', function() {
|
|
createDiffLayer();
|
|
createOsmLayer();
|
|
createCountyLayer();
|
|
});
|
|
|
|
// Click on empty map area closes any open popup and clears selection
|
|
map.on('click', function(e) {
|
|
if (multiSelectMode) return;
|
|
if (e.originalEvent && e.originalEvent.shiftKey) return;
|
|
clearSelection();
|
|
});
|
|
|
|
// Load button
|
|
document.getElementById('loadButton').addEventListener('click', function() {
|
|
loadFiles();
|
|
// Auto-close controls on mobile after clicking load
|
|
if (window.innerWidth <= 768) {
|
|
setTimeout(closeControls, 300);
|
|
}
|
|
});
|
|
|
|
// Save button
|
|
document.getElementById('saveButton').addEventListener('click', saveAcceptedItems);
|
|
|
|
// Multi-select toggle button
|
|
document.getElementById('multiSelectToggle').addEventListener('click', function() {
|
|
multiSelectMode = !multiSelectMode;
|
|
const button = document.getElementById('multiSelectToggle');
|
|
if (multiSelectMode) {
|
|
button.style.background = '#007bff';
|
|
button.textContent = 'Multi-Select: ON';
|
|
map.getContainer().classList.add('multi-select-active');
|
|
} else {
|
|
button.style.background = '#6c757d';
|
|
button.textContent = 'Multi-Select: OFF';
|
|
map.getContainer().classList.remove('multi-select-active');
|
|
// Clear selection when turning off multi-select mode
|
|
clearSelection();
|
|
}
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
});
|