job folders, selection

This commit is contained in:
zyphlar
2026-02-25 10:25:48 -08:00
parent d617750a46
commit de7789442d
5 changed files with 975 additions and 217 deletions
+599 -174
View File
@@ -6,13 +6,22 @@ let countyLayer;
let osmData = null;
let diffData = null;
let countyData = null;
let selectedFeature = null;
let selectedLayer = 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;
// Initialize map
function initMap() {
map = L.map('map').setView([28.7, -81.7], 12);
@@ -32,6 +41,170 @@ function initMap() {
map.getPane('osmPane').style.zIndex = 400;
map.getPane('diffPane').style.zIndex = 401;
map.getPane('countyPane').style.zIndex = 402;
// 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
@@ -212,7 +385,8 @@ function createOsmLayer() {
});
layer.on('mouseover', function(e) {
if (selectedLayer !== layer) {
const isSelected = selectedLayers.some(l => l.layer === layer);
if (!isSelected) {
if (isPoint) {
layer.setStyle({
radius: 8,
@@ -228,7 +402,8 @@ function createOsmLayer() {
});
layer.on('mouseout', function(e) {
if (selectedLayer !== layer) {
const isSelected = selectedLayers.some(l => l.layer === layer);
if (!isSelected) {
if (isPoint) {
layer.setStyle(osmMarkerStyle(feature));
} else {
@@ -237,7 +412,12 @@ function createOsmLayer() {
}
});
}
}).addTo(map);
});
// Only add to map if checkbox is checked
if (document.getElementById('osmToggle').checked) {
osmLayer.addTo(map);
}
updateLayerZIndex();
}
@@ -286,7 +466,8 @@ function createDiffLayer() {
});
layer.on('mouseover', function(e) {
if (selectedLayer !== layer) {
const isSelected = selectedLayers.some(l => l.layer === layer);
if (!isSelected) {
if (isPoint) {
layer.setStyle({
radius: 8,
@@ -302,7 +483,8 @@ function createDiffLayer() {
});
layer.on('mouseout', function(e) {
if (selectedLayer !== layer) {
const isSelected = selectedLayers.some(l => l.layer === layer);
if (!isSelected) {
if (isPoint) {
layer.setStyle(diffMarkerStyle(feature));
} else {
@@ -311,7 +493,12 @@ function createDiffLayer() {
}
});
}
}).addTo(map);
});
// Only add to map if checkbox is checked
if (document.getElementById('diffToggle').checked) {
diffLayer.addTo(map);
}
updateLayerZIndex();
}
@@ -350,7 +537,8 @@ function createCountyLayer() {
});
layer.on('mouseover', function(e) {
if (selectedLayer !== layer) {
const isSelected = selectedLayers.some(l => l.layer === layer);
if (!isSelected) {
if (isPoint) {
layer.setStyle({
radius: 8,
@@ -366,7 +554,8 @@ function createCountyLayer() {
});
layer.on('mouseout', function(e) {
if (selectedLayer !== layer) {
const isSelected = selectedLayers.some(l => l.layer === layer);
if (!isSelected) {
if (isPoint) {
layer.setStyle(countyMarkerStyle(feature));
} else {
@@ -387,112 +576,216 @@ function createCountyLayer() {
// Select a feature from any layer
function selectFeature(feature, layer, e, layerType = 'diff') {
// Deselect previous feature
if (selectedLayer) {
const isPoint = selectedLayer.feature.geometry.type === 'Point';
// Get the appropriate style function based on previous layer type
if (isPoint) {
const markerStyleFunc = selectedLayer._layerType === 'diff' ? diffMarkerStyle :
selectedLayer._layerType === 'osm' ? osmMarkerStyle : countyMarkerStyle;
selectedLayer.setStyle(markerStyleFunc(selectedLayer.feature));
} else {
const styleFunc = selectedLayer._layerType === 'diff' ? diffStyle :
selectedLayer._layerType === 'osm' ? osmStyle : countyStyle;
selectedLayer.setStyle(styleFunc(selectedLayer.feature));
}
// 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);
selectedFeature = feature;
selectedLayer = layer;
selectedLayer._layerType = layerType; // Store layer type for later
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'
});
}
// 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: address fields first, then name/highway, then alphabetical
const priorityOrder = {
'addr:housenumber': 0,
'addr:street': 1,
'addr:unit': 2,
'addr:city': 3,
'addr:postcode': 4,
'addr:state': 5,
'name': 10,
'highway': 11
};
const aPriority = priorityOrder[a] ?? 999;
const bPriority = priorityOrder[b] ?? 999;
if (aPriority !== bPriority) {
return aPriority - bPriority;
// 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));
}
return a.localeCompare(b);
});
} else {
// Add to selection
selectedFeatures.push(feature);
selectedLayers.push({ layer: layer, type: layerType });
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>';
// 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'
});
}
}
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>';
}
// Update popup with current selection
if (selectedFeatures.length > 0) {
showMultiFeaturePopup(e.latlng);
} else {
clearSelection();
}
} else {
// Regular click - replace selection
clearSelection();
html += '</div>';
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
};
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,
@@ -500,85 +793,84 @@ function selectFeature(feature, layer, e, layerType = 'diff') {
autoClose: false,
closeOnClick: false
})
.setLatLng(e.latlng)
.setLatLng(latlng)
.setContent(html)
.openOn(map);
// Handle popup close
featurePopup.on('remove', function() {
if (selectedLayer) {
const isPoint = selectedLayer.feature.geometry.type === 'Point';
if (isPoint) {
const markerStyleFunc = selectedLayer._layerType === 'diff' ? diffMarkerStyle :
selectedLayer._layerType === 'osm' ? osmMarkerStyle : countyMarkerStyle;
selectedLayer.setStyle(markerStyleFunc(selectedLayer.feature));
} else {
const styleFunc = selectedLayer._layerType === 'diff' ? diffStyle :
selectedLayer._layerType === 'osm' ? osmStyle : countyStyle;
selectedLayer.setStyle(styleFunc(selectedLayer.feature));
}
selectedLayer = null;
selectedFeature = null;
}
clearSelection();
});
}
// Accept a feature
function acceptFeature() {
if (!selectedFeature) return;
// Accept all selected features
function acceptAllFeatures() {
if (selectedFeatures.length === 0) return;
// Remove from rejected if present
rejectedFeatures.delete(selectedFeature);
selectedFeatures.forEach((feature, index) => {
// Remove from rejected if present
rejectedFeatures.delete(feature);
// Add to accepted
acceptedFeatures.add(selectedFeature);
// Add to accepted
acceptedFeatures.add(feature);
// Update layer style
if (selectedLayer) {
const isPoint = selectedFeature.geometry.type === 'Point';
if (isPoint) {
selectedLayer.setStyle(diffMarkerStyle(selectedFeature));
} else {
selectedLayer.setStyle(diffStyle(selectedFeature));
// 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 a feature
function rejectFeature() {
if (!selectedFeature) return;
// Reject all selected features
function rejectAllFeatures() {
if (selectedFeatures.length === 0) return;
// Remove from accepted if present
acceptedFeatures.delete(selectedFeature);
selectedFeatures.forEach((feature, index) => {
// Remove from accepted if present
acceptedFeatures.delete(feature);
// Add to rejected
rejectedFeatures.add(selectedFeature);
// Add to rejected
rejectedFeatures.add(feature);
// Update layer style
if (selectedLayer) {
const isPoint = selectedFeature.geometry.type === 'Point';
if (isPoint) {
selectedLayer.setStyle(diffMarkerStyle(selectedFeature));
} else {
selectedLayer.setStyle(diffStyle(selectedFeature));
// 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();
@@ -586,8 +878,8 @@ function rejectFeature() {
}
// Expose functions globally for onclick handlers
window.acceptFeature = acceptFeature;
window.rejectFeature = rejectFeature;
window.acceptAllFeatures = acceptAllFeatures;
window.rejectAllFeatures = rejectAllFeatures;
// Update save button state
function updateSaveButton() {
@@ -616,7 +908,7 @@ async function loadFiles() {
const dataType = document.getElementById('dataTypeSelect').value;
// Build file paths based on county and data type
let osmFile, diffFile, countyFile;
let osmFile, diffFile, countyFile, diffAddedFile, diffRemovedFile;
if (dataType === 'roads') {
osmFile = `latest/${county}/osm-roads.geojson`;
@@ -627,16 +919,55 @@ async function loadFiles() {
diffFile = `latest/${county}/diff-paths.geojson`;
countyFile = `latest/${county}/county-paths.geojson`;
} else if (dataType === 'addresses') {
osmFile = `osm_cache/osm_addresses_${county}_20251207.geojson`;
diffFile = `latest/${county}/addresses-to-add.geojson`;
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;
diffData = diffFile ? await loadFromServer(`/data/${diffFile}`) : 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;
@@ -666,37 +997,83 @@ async function loadFiles() {
}
}
// Save accepted and rejected items to original diff file
// Save accepted items to separate files (added-approved.geojson and removed-approved.geojson)
async function saveAcceptedItems() {
if (!diffData || (acceptedFeatures.size === 0 && rejectedFeatures.size === 0)) {
showStatus('No features to save', 'error');
if (!diffData || acceptedFeatures.size === 0) {
showStatus('No accepted features to save', 'error');
return;
}
try {
// Add accepted=true or accepted=false property to features
// Separate accepted features into added and removed
const acceptedAdded = [];
const acceptedRemoved = [];
diffData.features.forEach(feature => {
if (acceptedFeatures.has(feature)) {
feature.properties.accepted = true;
} else if (rejectedFeatures.has(feature)) {
feature.properties.accepted = false;
// 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);
}
}
});
// Create download
const dataStr = JSON.stringify(diffData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
// Create and download added-approved.geojson
if (acceptedAdded.length > 0) {
const addedData = {
type: 'FeatureCollection',
features: acceptedAdded
};
const addedStr = JSON.stringify(addedData, null, 2);
const addedBlob = new Blob([addedStr], { type: 'application/json' });
const addedUrl = URL.createObjectURL(addedBlob);
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);
const addedLink = document.createElement('a');
addedLink.href = addedUrl;
addedLink.download = 'added-approved.geojson';
document.body.appendChild(addedLink);
addedLink.click();
document.body.removeChild(addedLink);
URL.revokeObjectURL(addedUrl);
}
showStatus(`Saved ${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
// Create and download removed-approved.geojson
if (acceptedRemoved.length > 0) {
const removedData = {
type: 'FeatureCollection',
features: acceptedRemoved
};
const removedStr = JSON.stringify(removedData, null, 2);
const removedBlob = new Blob([removedStr], { type: 'application/json' });
const removedUrl = URL.createObjectURL(removedBlob);
const removedLink = document.createElement('a');
removedLink.href = removedUrl;
removedLink.download = 'removed-approved.geojson';
document.body.appendChild(removedLink);
removedLink.click();
document.body.removeChild(removedLink);
URL.revokeObjectURL(removedUrl);
}
showStatus(`Saved ${acceptedAdded.length} added, ${acceptedRemoved.length} removed (approved only)`, 'success');
} catch (error) {
showStatus(`Save failed: ${error.message}`, 'error');
@@ -753,6 +1130,33 @@ function toggleLayer(layerId, layer) {
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);
@@ -782,11 +1186,32 @@ document.addEventListener('DOMContentLoaded', function() {
});
// Load button
document.getElementById('loadButton').addEventListener('click', loadFiles);
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';
} else {
button.style.background = '#6c757d';
button.textContent = 'Multi-Select: OFF';
// 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');