From de7789442dc9725a8ce12f52d888ff55a2d26a1d Mon Sep 17 00:00:00 2001 From: zyphlar Date: Wed, 25 Feb 2026 10:25:48 -0800 Subject: [PATCH] job folders, selection --- compare-addresses.py | 100 +++-- web/server.py | 56 ++- web/static/map.js | 773 ++++++++++++++++++++++++++++++--------- web/templates/index.html | 122 +++++- web/templates/map.html | 141 ++++++- 5 files changed, 975 insertions(+), 217 deletions(-) diff --git a/compare-addresses.py b/compare-addresses.py index 8867da5..6443f52 100644 --- a/compare-addresses.py +++ b/compare-addresses.py @@ -14,6 +14,7 @@ Usage: import argparse import json import os +import shutil import sys import zipfile from datetime import datetime @@ -39,10 +40,10 @@ warnings.filterwarnings('ignore') class AddressComparator: - def __init__(self, tolerance_meters: float = 50.0, cache_dir: str = "osm_cache"): + def __init__(self, tolerance_meters: float = 500.0, cache_dir: str = "osm_cache"): """ Initialize the address comparator. - + Args: tolerance_meters: Distance tolerance for considering addresses as matching cache_dir: Directory to cache OSM data @@ -50,32 +51,68 @@ class AddressComparator: self.tolerance_meters = tolerance_meters self.cache_dir = Path(cache_dir) self.cache_dir.mkdir(exist_ok=True) - + # Convert meters to degrees (approximate) # 1 degree latitude ≈ 111,000 meters self.tolerance_deg = tolerance_meters / 111000.0 - - def download_osm_addresses(self, county: str, state: str, output_file: str = None) -> str: + + def _get_county_area_id(self, county: str, state: str) -> int: + """Get OSM area ID for a county using Nominatim.""" + search_query = f"{county} County, {state}, USA" + url = f"https://nominatim.openstreetmap.org/search?q={urllib.parse.quote(search_query)}&format=json&limit=1&featuretype=county" + + # Nominatim requires User-Agent header + req = urllib.request.Request(url, headers={'User-Agent': 'TheVillagesImport/1.0'}) + + try: + with urllib.request.urlopen(req) as response: + results = json.loads(response.read().decode("utf-8")) + + if results and results[0].get('osm_type') == 'relation': + relation_id = int(results[0]['osm_id']) + area_id = relation_id + 3600000000 + print(f"Found {county} County, {state}: relation {relation_id} -> area {area_id}") + return area_id + + raise ValueError(f"Could not find relation for {county} County, {state}") + except urllib.error.HTTPError as e: + print(f"Nominatim HTTP Error {e.code}: {e.reason}", file=sys.stderr) + sys.exit(1) + + def download_osm_addresses(self, county: str, state: str, output_file: str = None, output_dir: str = None) -> str: """Download address data from OpenStreetMap via Overpass API.""" - if output_file is None: - timestamp = datetime.now().strftime("%Y%m%d") - output_file = self.cache_dir / f"osm_addresses_{county.lower()}_{timestamp}.geojson" + # Determine cache file location (with timestamp for caching) + timestamp = datetime.now().strftime("%Y%m%d") + cache_file = self.cache_dir / f"osm_addresses_{county.lower()}_{timestamp}.geojson" + + # Determine final output file location (standard name for web serving) + if output_file is not None: + final_output_file = Path(output_file) + elif output_dir is not None: + final_output_file = Path(output_dir) / "osm-addresses.geojson" else: - output_file = Path(output_file) + final_output_file = cache_file # Check if cached file exists and is recent (less than 7 days old) - if output_file.exists(): - file_age = datetime.now().timestamp() - output_file.stat().st_mtime + if cache_file.exists(): + file_age = datetime.now().timestamp() - cache_file.stat().st_mtime if file_age < 7 * 24 * 3600: # 7 days in seconds - print(f"Using cached OSM data: {output_file}") - return str(output_file) + print(f"Using cached OSM data: {cache_file}") + # Copy cache to final output location if different + if final_output_file != cache_file: + final_output_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(cache_file, final_output_file) + print(f"Copied to: {final_output_file}") + return str(final_output_file) print(f"Downloading OSM addresses for {county} County, {state}...") - - # Build Overpass query for addresses + + # Get the specific area ID for this county + area_id = self._get_county_area_id(county, state) + + # Build Overpass query for addresses using area ID query = f"""[out:json][timeout:180]; -area["name"="{state}"]->.state; -area["name"="{county} County"](area.state)->.searchArea; +area(id:{area_id})->.searchArea; nwr["addr:housenumber"](area.searchArea); out geom;""" @@ -84,14 +121,21 @@ out geom;""" # Convert to GeoJSON geojson = self._convert_osm_to_geojson(osm_data) - - # Save to file - output_file.parent.mkdir(parents=True, exist_ok=True) - with open(output_file, 'w', encoding='utf-8') as f: + + # Save to cache file + cache_file.parent.mkdir(parents=True, exist_ok=True) + with open(cache_file, 'w', encoding='utf-8') as f: json.dump(geojson, f, indent=2) - - print(f"Downloaded {len(geojson['features'])} OSM addresses to {output_file}") - return str(output_file) + print(f"Downloaded {len(geojson['features'])} OSM addresses to cache: {cache_file}") + + # Also save to final output location if different + if final_output_file != cache_file: + final_output_file.parent.mkdir(parents=True, exist_ok=True) + with open(final_output_file, 'w', encoding='utf-8') as f: + json.dump(geojson, f, indent=2) + print(f"Saved to: {final_output_file}") + + return str(final_output_file) def _query_overpass(self, query: str) -> Dict[str, Any]: """Send query to Overpass API and return JSON response.""" @@ -581,8 +625,8 @@ Examples: parser.add_argument('county', help='County name (e.g., "Lake", "Sumter")') parser.add_argument('state', help='State name (e.g., "Florida")') parser.add_argument('--local-zip', required=True, help='Path to local address data ZIP file') - parser.add_argument('--tolerance', '-t', type=float, default=50.0, - help='Distance tolerance in meters for matching addresses (default: 50)') + parser.add_argument('--tolerance', '-t', type=float, default=500.0, + help='Distance tolerance in meters for matching addresses (default: 500)') parser.add_argument('--output-dir', '-o', help='Output directory for results (default: processed data/[County])') parser.add_argument('--cache-dir', default='osm_cache', help='Directory to cache OSM downloads (default: osm_cache)') @@ -625,8 +669,8 @@ Examples: # Remove existing cache for this county for cache_file in Path(args.cache_dir).glob(f"osm_addresses_{args.county.lower()}_*.geojson"): cache_file.unlink() - - osm_file = comparator.download_osm_addresses(args.county, args.state) + + osm_file = comparator.download_osm_addresses(args.county, args.state, output_dir=str(output_dir)) # Convert local data local_file = comparator.load_local_addresses(args.local_zip) diff --git a/web/server.py b/web/server.py index 087569f..21865ad 100644 --- a/web/server.py +++ b/web/server.py @@ -15,6 +15,9 @@ app = Flask(__name__, static_folder='static', template_folder='templates') running_processes = {} process_logs = {} +# Global var +latest_path = "/data/latest" + @app.route('/') def index(): """Main index page with script execution buttons""" @@ -31,12 +34,12 @@ def index(): } # Get list of files - counties = os.listdir('/data/latest') data_files = {} - for county in counties: - files = os.listdir('/data/latest/'+county) - data_files[county] = files - # data_files.append(county) + if os.path.exists(latest_path): + for countyFolder in os.listdir(latest_path): + files = os.listdir('/data/latest/'+countyFolder) + data_files[countyFolder] = files + # data_files.append(countyFolder) return render_template('index.html', script_map=script_map, @@ -52,7 +55,7 @@ def map_viewer(): def get_script_map(): """Get the map of available scripts and their commands""" return { - 'ls': 'ls -alr /data', + 'ls': 'ls -al /data', 'make-new-latest': 'cd /data && NEWDIR=$(date +%y%m%d) && mkdir -p $NEWDIR/lake $NEWDIR/sumter && ln -sfn $NEWDIR latest', # todo: make a clean-old-data script 'download-county-addresses': { @@ -112,10 +115,24 @@ def run_script(): data = request.json script_name = data.get('script') county = data.get('county', '') + force_download = data.get('forceDownload', False) if not script_name: return jsonify({'error': 'No script specified'}), 400 + # Check if /data/latest exists and is a symlink, not a regular directory + # Skip this check for make-new-latest, ls + skip_check_scripts = ['make-new-latest', 'ls'] + if script_name not in skip_check_scripts: + latest_path = '/data/latest' + if os.path.exists(latest_path) or os.path.islink(latest_path): + # Check if it's a directory but NOT a symlink + if os.path.isdir(latest_path) and not os.path.islink(latest_path): + return jsonify({'error': '/data/latest is a directory, not a symlink. Please run "Make New Latest" first.'}), 400 + else: + # /data/latest doesn't exist at all + return jsonify({'error': '/data/latest does not exist. Please run "Make New Latest" first.'}), 400 + script_map = get_script_map() if script_name not in script_map: @@ -138,7 +155,11 @@ def run_script(): if isinstance(cmd_config, str): cmd = ['bash', '-c', cmd_config] else: - cmd = cmd_config + cmd = list(cmd_config) # Make a copy to avoid modifying the original + + # Add --force-download flag for diff-addresses if requested + if script_name == 'diff-addresses' and force_download and isinstance(cmd, list): + cmd.append('--force-download') else: return jsonify({'error': 'Invalid script configuration'}), 400 @@ -189,6 +210,27 @@ def job_status(job_id): 'logs': logs }) +@app.route('/api/cancel-job', methods=['POST']) +def cancel_job(): + """Cancel a running job""" + data = request.json + job_id = data.get('job_id') + + if not job_id: + return jsonify({'error': 'No job ID specified'}), 400 + + if job_id not in running_processes: + return jsonify({'error': 'Job not found or already completed'}), 404 + + try: + process = running_processes[job_id] + process.terminate() + process_logs[job_id].append('\n[Job cancelled by user]') + del running_processes[job_id] + return jsonify({'success': True}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + @app.route('/api/list-files') def list_files(): """List available GeoJSON files""" diff --git a/web/static/map.js b/web/static/map.js index bfa69ee..23a554f 100644 --- a/web/static/map.js +++ b/web/static/map.js @@ -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 = '
'; - - // Show layer type - html += `
Layer: ${layerType.toUpperCase()}
`; - - // Only show status for diff layer - if (layerType === 'diff') { - html += `
Status: ${isRemoved ? 'Removed' : 'Added/Modified'}
`; - } - - // 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 += '
'; - for (const [key, value] of displayProps) { - html += `
${key}: ${value}
`; - } - html += '
'; - } - - // Only show accept/reject for diff layer - if (layerType === 'diff') { - if (isAccepted) { - html += '
✓ Accepted
'; - } else if (isRejected) { - html += '
✗ Rejected
'; + // 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 += '
'; - html += ''; - html += ''; - html += '
'; - } + // Update popup with current selection + if (selectedFeatures.length > 0) { + showMultiFeaturePopup(e.latlng); + } else { + clearSelection(); + } + } else { + // Regular click - replace selection + clearSelection(); - html += '
'; + 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 = '
'; + + // Show count + html += `
Selected: ${selectedFeatures.length} feature${selectedFeatures.length > 1 ? 's' : ''}
`; + + // 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 += '
'; + 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 += `
${key}: ${displayValue}
`; + } + html += '
'; + + // Show layer types if mixed + const layerTypes = selectedLayers.map(l => l.type); + const uniqueLayerTypes = [...new Set(layerTypes)]; + if (uniqueLayerTypes.length > 1) { + html += `
Layers: ${uniqueLayerTypes.join(', ')}
`; + } else { + html += `
Layer: ${uniqueLayerTypes[0].toUpperCase()}
`; + } + + // 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 += `
Status: ${isRemoved ? 'Removed (Red)' : 'Added (Green)'}
`; + } else if (addedCount > 0 && removedCount > 0) { + // Mixed selection + html += `
Status: ${addedCount} added, ${removedCount} removed
`; + } else if (addedCount > 0) { + html += `
Status: All Added (Green)
`; + } else if (removedCount > 0) { + html += `
Status: All Removed (Red)
`; + } + } + + // 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 += '
'; + if (acceptedCount > 0) html += `
✓ ${acceptedCount} accepted
`; + if (rejectedCount > 0) html += `
✗ ${rejectedCount} rejected
`; + html += '
'; + } + + // 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 += '
'; + html += ``; + html += ``; + html += '
'; + } + } + + html += '
'; + // 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'); diff --git a/web/templates/index.html b/web/templates/index.html index 789469d..68f8d93 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -164,6 +164,47 @@ margin-bottom: 10px; font-size: 16px; } + + .button-with-checkbox { + display: flex; + align-items: center; + gap: 10px; + } + + .button-with-checkbox label { + display: flex; + align-items: center; + gap: 5px; + font-size: 13px; + color: #666; + cursor: pointer; + white-space: nowrap; + } + + .button-with-checkbox input[type="checkbox"] { + cursor: pointer; + } + + .cancel-button { + padding: 10px 20px; + background: #dc3545; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: background 0.2s; + display: none; + } + + .cancel-button:hover { + background: #c82333; + } + + .cancel-button.active { + display: inline-block; + } @@ -198,14 +239,30 @@ {# County-specific commands #}
{% if 'lake' in script_config %} - +
+ + {% if script_name == 'diff-addresses' %} + + {% endif %} +
{% endif %} {% if 'sumter' in script_config %} - +
+ + {% if script_name == 'diff-addresses' %} + + {% endif %} +
{% endif %}
{% endif %} @@ -243,6 +300,7 @@

Script Output

+
@@ -269,6 +327,19 @@ btn.disabled = true; }); + // Show cancel button + document.getElementById('cancelButton').classList.add('active'); + + // Check if force redownload is checked (for diff-addresses) + let forceDownload = false; + if (scriptName === 'diff-addresses') { + const checkboxId = `force-redownload-${county}`; + const checkbox = document.getElementById(checkboxId); + if (checkbox) { + forceDownload = checkbox.checked; + } + } + fetch('/api/run-script', { method: 'POST', headers: { @@ -276,7 +347,8 @@ }, body: JSON.stringify({ script: scriptName, - county: county + county: county, + forceDownload: forceDownload }) }) .then(response => response.json()) @@ -284,6 +356,7 @@ if (data.error) { showStatus(`Error: ${data.error}`, 'error'); enableButtons(); + document.getElementById('cancelButton').classList.remove('active'); return; } @@ -300,6 +373,40 @@ .catch(error => { showStatus(`Error: ${error.message}`, 'error'); enableButtons(); + document.getElementById('cancelButton').classList.remove('active'); + }); + } + + function cancelJob() { + if (!currentJobId) return; + + if (!confirm('Are you sure you want to cancel this job?')) { + return; + } + + fetch('/api/cancel-job', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + job_id: currentJobId + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showStatus('Job cancelled', 'info'); + clearInterval(logCheckInterval); + enableButtons(); + document.getElementById('cancelButton').classList.remove('active'); + currentJobId = null; + } else { + showStatus(`Error cancelling job: ${data.error}`, 'error'); + } + }) + .catch(error => { + showStatus(`Error cancelling job: ${error.message}`, 'error'); }); } @@ -319,6 +426,7 @@ clearInterval(logCheckInterval); showStatus('Script completed', 'success'); enableButtons(); + document.getElementById('cancelButton').classList.remove('active'); currentJobId = null; } }) diff --git a/web/templates/map.html b/web/templates/map.html index 77e7e54..000af77 100644 --- a/web/templates/map.html +++ b/web/templates/map.html @@ -149,12 +149,143 @@ .load-button:hover { background: #218838 !important; } + + .selection-box { + position: absolute; + border: 2px dashed #007bff; + background: rgba(0, 123, 255, 0.1); + pointer-events: none; + z-index: 1000; + } + + .hamburger-button { + display: none; + position: absolute; + top: 10px; + right: 10px; + z-index: 1001; + background: white; + border: none; + border-radius: 4px; + padding: 10px; + cursor: pointer; + box-shadow: 0 2px 10px rgba(0,0,0,0.2); + width: 44px; + height: 44px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 4px; + } + + .hamburger-button span { + display: block; + width: 24px; + height: 3px; + background: #333; + border-radius: 2px; + transition: all 0.3s ease; + } + + .hamburger-button.active span:nth-child(1) { + transform: rotate(45deg) translate(6px, 6px); + } + + .hamburger-button.active span:nth-child(2) { + opacity: 0; + } + + .hamburger-button.active span:nth-child(3) { + transform: rotate(-45deg) translate(6px, -6px); + } + + .close-button { + display: none; + position: absolute; + top: 10px; + right: 10px; + background: transparent; + border: none; + font-size: 24px; + cursor: pointer; + padding: 5px; + line-height: 1; + color: #666; + } + + .close-button:hover { + color: #333; + } + + /* Mobile styles */ + @media (max-width: 768px) { + .hamburger-button { + display: flex; + } + + .close-button { + display: block; + } + + .controls { + position: fixed; + top: 0; + right: -100%; + width: 80%; + max-width: 320px; + height: 100vh; + overflow-y: auto; + transition: right 0.3s ease; + border-radius: 0; + box-shadow: -2px 0 10px rgba(0,0,0,0.2); + padding-top: 50px; + } + + .controls.open { + right: 0; + } + + .controls h3 { + font-size: 13px; + } + + .controls label { + font-size: 12px; + } + } + + /* Overlay for mobile */ + .overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.5); + z-index: 999; + } + + .overlay.active { + display: block; + }
-
+ + + + +
+ +
+

Layer Controls (top to bottom)

@@ -185,6 +316,14 @@ Hide highway=service +

Selection Mode

+ +
+ Tip: Enable multi-select or hold Shift to select multiple features by clicking or dragging a box +
+

Load Data