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 #}
{% 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