job folders, selection
This commit is contained in:
+64
-20
@@ -14,6 +14,7 @@ Usage:
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -39,7 +40,7 @@ warnings.filterwarnings('ignore')
|
|||||||
|
|
||||||
|
|
||||||
class AddressComparator:
|
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.
|
Initialize the address comparator.
|
||||||
|
|
||||||
@@ -55,27 +56,63 @@ class AddressComparator:
|
|||||||
# 1 degree latitude ≈ 111,000 meters
|
# 1 degree latitude ≈ 111,000 meters
|
||||||
self.tolerance_deg = tolerance_meters / 111000.0
|
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."""
|
"""Download address data from OpenStreetMap via Overpass API."""
|
||||||
if output_file is None:
|
# Determine cache file location (with timestamp for caching)
|
||||||
timestamp = datetime.now().strftime("%Y%m%d")
|
timestamp = datetime.now().strftime("%Y%m%d")
|
||||||
output_file = self.cache_dir / f"osm_addresses_{county.lower()}_{timestamp}.geojson"
|
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:
|
else:
|
||||||
output_file = Path(output_file)
|
final_output_file = cache_file
|
||||||
|
|
||||||
# Check if cached file exists and is recent (less than 7 days old)
|
# Check if cached file exists and is recent (less than 7 days old)
|
||||||
if output_file.exists():
|
if cache_file.exists():
|
||||||
file_age = datetime.now().timestamp() - output_file.stat().st_mtime
|
file_age = datetime.now().timestamp() - cache_file.stat().st_mtime
|
||||||
if file_age < 7 * 24 * 3600: # 7 days in seconds
|
if file_age < 7 * 24 * 3600: # 7 days in seconds
|
||||||
print(f"Using cached OSM data: {output_file}")
|
print(f"Using cached OSM data: {cache_file}")
|
||||||
return str(output_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}...")
|
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];
|
query = f"""[out:json][timeout:180];
|
||||||
area["name"="{state}"]->.state;
|
area(id:{area_id})->.searchArea;
|
||||||
area["name"="{county} County"](area.state)->.searchArea;
|
|
||||||
nwr["addr:housenumber"](area.searchArea);
|
nwr["addr:housenumber"](area.searchArea);
|
||||||
out geom;"""
|
out geom;"""
|
||||||
|
|
||||||
@@ -85,13 +122,20 @@ out geom;"""
|
|||||||
# Convert to GeoJSON
|
# Convert to GeoJSON
|
||||||
geojson = self._convert_osm_to_geojson(osm_data)
|
geojson = self._convert_osm_to_geojson(osm_data)
|
||||||
|
|
||||||
# Save to file
|
# Save to cache file
|
||||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(output_file, 'w', encoding='utf-8') as f:
|
with open(cache_file, 'w', encoding='utf-8') as f:
|
||||||
json.dump(geojson, f, indent=2)
|
json.dump(geojson, f, indent=2)
|
||||||
|
print(f"Downloaded {len(geojson['features'])} OSM addresses to cache: {cache_file}")
|
||||||
|
|
||||||
print(f"Downloaded {len(geojson['features'])} OSM addresses to {output_file}")
|
# Also save to final output location if different
|
||||||
return str(output_file)
|
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]:
|
def _query_overpass(self, query: str) -> Dict[str, Any]:
|
||||||
"""Send query to Overpass API and return JSON response."""
|
"""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('county', help='County name (e.g., "Lake", "Sumter")')
|
||||||
parser.add_argument('state', help='State name (e.g., "Florida")')
|
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('--local-zip', required=True, help='Path to local address data ZIP file')
|
||||||
parser.add_argument('--tolerance', '-t', type=float, default=50.0,
|
parser.add_argument('--tolerance', '-t', type=float, default=500.0,
|
||||||
help='Distance tolerance in meters for matching addresses (default: 50)')
|
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('--output-dir', '-o', help='Output directory for results (default: processed data/[County])')
|
||||||
parser.add_argument('--cache-dir', default='osm_cache',
|
parser.add_argument('--cache-dir', default='osm_cache',
|
||||||
help='Directory to cache OSM downloads (default: osm_cache)')
|
help='Directory to cache OSM downloads (default: osm_cache)')
|
||||||
@@ -626,7 +670,7 @@ Examples:
|
|||||||
for cache_file in Path(args.cache_dir).glob(f"osm_addresses_{args.county.lower()}_*.geojson"):
|
for cache_file in Path(args.cache_dir).glob(f"osm_addresses_{args.county.lower()}_*.geojson"):
|
||||||
cache_file.unlink()
|
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
|
# Convert local data
|
||||||
local_file = comparator.load_local_addresses(args.local_zip)
|
local_file = comparator.load_local_addresses(args.local_zip)
|
||||||
|
|||||||
+49
-7
@@ -15,6 +15,9 @@ app = Flask(__name__, static_folder='static', template_folder='templates')
|
|||||||
running_processes = {}
|
running_processes = {}
|
||||||
process_logs = {}
|
process_logs = {}
|
||||||
|
|
||||||
|
# Global var
|
||||||
|
latest_path = "/data/latest"
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""Main index page with script execution buttons"""
|
"""Main index page with script execution buttons"""
|
||||||
@@ -31,12 +34,12 @@ def index():
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Get list of files
|
# Get list of files
|
||||||
counties = os.listdir('/data/latest')
|
|
||||||
data_files = {}
|
data_files = {}
|
||||||
for county in counties:
|
if os.path.exists(latest_path):
|
||||||
files = os.listdir('/data/latest/'+county)
|
for countyFolder in os.listdir(latest_path):
|
||||||
data_files[county] = files
|
files = os.listdir('/data/latest/'+countyFolder)
|
||||||
# data_files.append(county)
|
data_files[countyFolder] = files
|
||||||
|
# data_files.append(countyFolder)
|
||||||
|
|
||||||
return render_template('index.html',
|
return render_template('index.html',
|
||||||
script_map=script_map,
|
script_map=script_map,
|
||||||
@@ -52,7 +55,7 @@ def map_viewer():
|
|||||||
def get_script_map():
|
def get_script_map():
|
||||||
"""Get the map of available scripts and their commands"""
|
"""Get the map of available scripts and their commands"""
|
||||||
return {
|
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',
|
'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
|
# todo: make a clean-old-data script
|
||||||
'download-county-addresses': {
|
'download-county-addresses': {
|
||||||
@@ -112,10 +115,24 @@ def run_script():
|
|||||||
data = request.json
|
data = request.json
|
||||||
script_name = data.get('script')
|
script_name = data.get('script')
|
||||||
county = data.get('county', '')
|
county = data.get('county', '')
|
||||||
|
force_download = data.get('forceDownload', False)
|
||||||
|
|
||||||
if not script_name:
|
if not script_name:
|
||||||
return jsonify({'error': 'No script specified'}), 400
|
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()
|
script_map = get_script_map()
|
||||||
|
|
||||||
if script_name not in script_map:
|
if script_name not in script_map:
|
||||||
@@ -138,7 +155,11 @@ def run_script():
|
|||||||
if isinstance(cmd_config, str):
|
if isinstance(cmd_config, str):
|
||||||
cmd = ['bash', '-c', cmd_config]
|
cmd = ['bash', '-c', cmd_config]
|
||||||
else:
|
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:
|
else:
|
||||||
return jsonify({'error': 'Invalid script configuration'}), 400
|
return jsonify({'error': 'Invalid script configuration'}), 400
|
||||||
|
|
||||||
@@ -189,6 +210,27 @@ def job_status(job_id):
|
|||||||
'logs': logs
|
'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')
|
@app.route('/api/list-files')
|
||||||
def list_files():
|
def list_files():
|
||||||
"""List available GeoJSON files"""
|
"""List available GeoJSON files"""
|
||||||
|
|||||||
+542
-117
@@ -6,13 +6,22 @@ let countyLayer;
|
|||||||
let osmData = null;
|
let osmData = null;
|
||||||
let diffData = null;
|
let diffData = null;
|
||||||
let countyData = null;
|
let countyData = null;
|
||||||
let selectedFeature = null;
|
let selectedFeatures = [];
|
||||||
let selectedLayer = null;
|
let selectedLayers = [];
|
||||||
let acceptedFeatures = new Set();
|
let acceptedFeatures = new Set();
|
||||||
let rejectedFeatures = new Set();
|
let rejectedFeatures = new Set();
|
||||||
let featurePopup = null;
|
let featurePopup = null;
|
||||||
let layerOrder = ['diff', 'osm', 'county']; // Default layer order (top to bottom)
|
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
|
// Initialize map
|
||||||
function initMap() {
|
function initMap() {
|
||||||
map = L.map('map').setView([28.7, -81.7], 12);
|
map = L.map('map').setView([28.7, -81.7], 12);
|
||||||
@@ -32,6 +41,170 @@ function initMap() {
|
|||||||
map.getPane('osmPane').style.zIndex = 400;
|
map.getPane('osmPane').style.zIndex = 400;
|
||||||
map.getPane('diffPane').style.zIndex = 401;
|
map.getPane('diffPane').style.zIndex = 401;
|
||||||
map.getPane('countyPane').style.zIndex = 402;
|
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
|
// Calculate bounds for all loaded layers
|
||||||
@@ -212,7 +385,8 @@ function createOsmLayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
layer.on('mouseover', function(e) {
|
layer.on('mouseover', function(e) {
|
||||||
if (selectedLayer !== layer) {
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
||||||
|
if (!isSelected) {
|
||||||
if (isPoint) {
|
if (isPoint) {
|
||||||
layer.setStyle({
|
layer.setStyle({
|
||||||
radius: 8,
|
radius: 8,
|
||||||
@@ -228,7 +402,8 @@ function createOsmLayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
layer.on('mouseout', function(e) {
|
layer.on('mouseout', function(e) {
|
||||||
if (selectedLayer !== layer) {
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
||||||
|
if (!isSelected) {
|
||||||
if (isPoint) {
|
if (isPoint) {
|
||||||
layer.setStyle(osmMarkerStyle(feature));
|
layer.setStyle(osmMarkerStyle(feature));
|
||||||
} else {
|
} 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();
|
updateLayerZIndex();
|
||||||
}
|
}
|
||||||
@@ -286,7 +466,8 @@ function createDiffLayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
layer.on('mouseover', function(e) {
|
layer.on('mouseover', function(e) {
|
||||||
if (selectedLayer !== layer) {
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
||||||
|
if (!isSelected) {
|
||||||
if (isPoint) {
|
if (isPoint) {
|
||||||
layer.setStyle({
|
layer.setStyle({
|
||||||
radius: 8,
|
radius: 8,
|
||||||
@@ -302,7 +483,8 @@ function createDiffLayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
layer.on('mouseout', function(e) {
|
layer.on('mouseout', function(e) {
|
||||||
if (selectedLayer !== layer) {
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
||||||
|
if (!isSelected) {
|
||||||
if (isPoint) {
|
if (isPoint) {
|
||||||
layer.setStyle(diffMarkerStyle(feature));
|
layer.setStyle(diffMarkerStyle(feature));
|
||||||
} else {
|
} 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();
|
updateLayerZIndex();
|
||||||
}
|
}
|
||||||
@@ -350,7 +537,8 @@ function createCountyLayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
layer.on('mouseover', function(e) {
|
layer.on('mouseover', function(e) {
|
||||||
if (selectedLayer !== layer) {
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
||||||
|
if (!isSelected) {
|
||||||
if (isPoint) {
|
if (isPoint) {
|
||||||
layer.setStyle({
|
layer.setStyle({
|
||||||
radius: 8,
|
radius: 8,
|
||||||
@@ -366,7 +554,8 @@ function createCountyLayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
layer.on('mouseout', function(e) {
|
layer.on('mouseout', function(e) {
|
||||||
if (selectedLayer !== layer) {
|
const isSelected = selectedLayers.some(l => l.layer === layer);
|
||||||
|
if (!isSelected) {
|
||||||
if (isPoint) {
|
if (isPoint) {
|
||||||
layer.setStyle(countyMarkerStyle(feature));
|
layer.setStyle(countyMarkerStyle(feature));
|
||||||
} else {
|
} else {
|
||||||
@@ -387,24 +576,71 @@ function createCountyLayer() {
|
|||||||
|
|
||||||
// Select a feature from any layer
|
// Select a feature from any layer
|
||||||
function selectFeature(feature, layer, e, layerType = 'diff') {
|
function selectFeature(feature, layer, e, layerType = 'diff') {
|
||||||
// Deselect previous feature
|
// Check if this is a multi-select attempt
|
||||||
if (selectedLayer) {
|
const isMultiSelect = (e.originalEvent && e.originalEvent.shiftKey) || multiSelectMode;
|
||||||
const isPoint = selectedLayer.feature.geometry.type === 'Point';
|
|
||||||
// Get the appropriate style function based on previous layer type
|
// Only allow multi-selection of diff layer features (OSM/county can't be accepted/rejected)
|
||||||
|
if (layerType !== 'diff' && isMultiSelect) {
|
||||||
|
return; // Block multi-selection of OSM/county layers
|
||||||
|
}
|
||||||
|
if (isMultiSelect) {
|
||||||
|
// Check if already selected
|
||||||
|
const alreadySelected = selectedFeatures.includes(feature);
|
||||||
|
if (alreadySelected) {
|
||||||
|
// Remove from selection
|
||||||
|
const index = selectedFeatures.indexOf(feature);
|
||||||
|
selectedFeatures.splice(index, 1);
|
||||||
|
const layerInfo = selectedLayers[index];
|
||||||
|
selectedLayers.splice(index, 1);
|
||||||
|
|
||||||
|
// Restore original style
|
||||||
|
const isPoint = feature.geometry.type === 'Point';
|
||||||
if (isPoint) {
|
if (isPoint) {
|
||||||
const markerStyleFunc = selectedLayer._layerType === 'diff' ? diffMarkerStyle :
|
const markerStyleFunc = layerType === 'diff' ? diffMarkerStyle :
|
||||||
selectedLayer._layerType === 'osm' ? osmMarkerStyle : countyMarkerStyle;
|
layerType === 'osm' ? osmMarkerStyle : countyMarkerStyle;
|
||||||
selectedLayer.setStyle(markerStyleFunc(selectedLayer.feature));
|
layer.setStyle(markerStyleFunc(feature));
|
||||||
} else {
|
} else {
|
||||||
const styleFunc = selectedLayer._layerType === 'diff' ? diffStyle :
|
const styleFunc = layerType === 'diff' ? diffStyle :
|
||||||
selectedLayer._layerType === 'osm' ? osmStyle : countyStyle;
|
layerType === 'osm' ? osmStyle : countyStyle;
|
||||||
selectedLayer.setStyle(styleFunc(selectedLayer.feature));
|
layer.setStyle(styleFunc(feature));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add to selection
|
||||||
|
selectedFeatures.push(feature);
|
||||||
|
selectedLayers.push({ layer: layer, type: layerType });
|
||||||
|
|
||||||
|
// Highlight
|
||||||
|
const isPoint = feature.geometry.type === 'Point';
|
||||||
|
if (isPoint) {
|
||||||
|
layer.setStyle({
|
||||||
|
radius: 9,
|
||||||
|
fillColor: '#ffc107',
|
||||||
|
color: '#ff9800',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 1
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
layer.setStyle({
|
||||||
|
weight: 6,
|
||||||
|
opacity: 1,
|
||||||
|
color: '#ffc107'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedFeature = feature;
|
// Update popup with current selection
|
||||||
selectedLayer = layer;
|
if (selectedFeatures.length > 0) {
|
||||||
selectedLayer._layerType = layerType; // Store layer type for later
|
showMultiFeaturePopup(e.latlng);
|
||||||
|
} else {
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular click - replace selection
|
||||||
|
clearSelection();
|
||||||
|
|
||||||
|
selectedFeatures = [feature];
|
||||||
|
selectedLayers = [{ layer: layer, type: layerType }];
|
||||||
|
|
||||||
const isPoint = feature.geometry.type === 'Point';
|
const isPoint = feature.geometry.type === 'Point';
|
||||||
if (isPoint) {
|
if (isPoint) {
|
||||||
@@ -424,27 +660,34 @@ function selectFeature(feature, layer, e, layerType = 'diff') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create popup near the clicked location
|
// Show popup for single or multiple features
|
||||||
const props = feature.properties || {};
|
showMultiFeaturePopup(e.latlng);
|
||||||
const isRemoved = props.removed === true || props.removed === 'True';
|
}
|
||||||
const isAccepted = acceptedFeatures.has(feature);
|
}
|
||||||
const isRejected = rejectedFeatures.has(feature);
|
|
||||||
|
// 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;">';
|
let html = '<div style="font-size: 12px; max-height: 400px; overflow-y: auto;">';
|
||||||
|
|
||||||
// Show layer type
|
// Show count
|
||||||
html += `<div style="margin-bottom: 8px;"><strong>Layer:</strong> ${layerType.toUpperCase()}</div>`;
|
html += `<div style="margin-bottom: 8px;"><strong>Selected:</strong> ${selectedFeatures.length} feature${selectedFeatures.length > 1 ? 's' : ''}</div>`;
|
||||||
|
|
||||||
// Only show status for diff layer
|
// Aggregate properties JOSM-style
|
||||||
if (layerType === 'diff') {
|
const allKeys = new Set();
|
||||||
html += `<div style="margin-bottom: 8px;"><strong>Status:</strong> ${isRemoved ? 'Removed' : 'Added/Modified'}</div>`;
|
selectedFeatures.forEach(feature => {
|
||||||
}
|
Object.keys(feature.properties || {}).forEach(key => {
|
||||||
|
if (key !== 'removed') allKeys.add(key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Display all non-null properties with custom ordering
|
// Priority order for display
|
||||||
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 = {
|
const priorityOrder = {
|
||||||
'addr:housenumber': 0,
|
'addr:housenumber': 0,
|
||||||
'addr:street': 1,
|
'addr:street': 1,
|
||||||
@@ -455,44 +698,94 @@ function selectFeature(feature, layer, e, layerType = 'diff') {
|
|||||||
'name': 10,
|
'name': 10,
|
||||||
'highway': 11
|
'highway': 11
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sortedKeys = Array.from(allKeys).sort((a, b) => {
|
||||||
const aPriority = priorityOrder[a] ?? 999;
|
const aPriority = priorityOrder[a] ?? 999;
|
||||||
const bPriority = priorityOrder[b] ?? 999;
|
const bPriority = priorityOrder[b] ?? 999;
|
||||||
|
|
||||||
if (aPriority !== bPriority) {
|
if (aPriority !== bPriority) {
|
||||||
return aPriority - bPriority;
|
return aPriority - bPriority;
|
||||||
}
|
}
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (displayProps.length > 0) {
|
// For each property, check if all values are the same
|
||||||
html += '<div style="font-size: 11px;">';
|
html += '<div style="font-size: 11px;">';
|
||||||
for (const [key, value] of displayProps) {
|
for (const key of sortedKeys) {
|
||||||
html += `<div style="margin: 2px 0;"><strong>${key}:</strong> ${value}</div>`;
|
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>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only show accept/reject for diff layer
|
// Show accept/reject buttons only if all selected features are from diff layer
|
||||||
if (layerType === 'diff') {
|
const allDiff = selectedLayers.every(l => l.type === 'diff');
|
||||||
if (isAccepted) {
|
if (allDiff) {
|
||||||
html += '<div style="margin-top: 8px; color: #007bff; font-weight: bold;">✓ Accepted</div>';
|
const buttonText = selectedFeatures.length === 1 ? '' : ' All';
|
||||||
} else if (isRejected) {
|
|
||||||
html += '<div style="margin-top: 8px; color: #4a4a4a; font-weight: bold;">✗ Rejected</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '<div style="margin-top: 10px; display: flex; gap: 5px;">';
|
html += '<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="acceptAllFeatures()" style="flex: 1; padding: 5px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">Accept${buttonText}</button>`;
|
||||||
html += '<button onclick="rejectFeature()" style="flex: 1; padding: 5px; background: #6c757d; color: white; border: none; border-radius: 3px; cursor: pointer;">Reject</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>';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
// Remove old popup if exists
|
|
||||||
if (featurePopup) {
|
|
||||||
map.closePopup(featurePopup);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create popup at click location
|
// Create popup at click location
|
||||||
featurePopup = L.popup({
|
featurePopup = L.popup({
|
||||||
maxWidth: 300,
|
maxWidth: 300,
|
||||||
@@ -500,85 +793,84 @@ function selectFeature(feature, layer, e, layerType = 'diff') {
|
|||||||
autoClose: false,
|
autoClose: false,
|
||||||
closeOnClick: false
|
closeOnClick: false
|
||||||
})
|
})
|
||||||
.setLatLng(e.latlng)
|
.setLatLng(latlng)
|
||||||
.setContent(html)
|
.setContent(html)
|
||||||
.openOn(map);
|
.openOn(map);
|
||||||
|
|
||||||
// Handle popup close
|
// Handle popup close
|
||||||
featurePopup.on('remove', function() {
|
featurePopup.on('remove', function() {
|
||||||
if (selectedLayer) {
|
clearSelection();
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accept a feature
|
// Accept all selected features
|
||||||
function acceptFeature() {
|
function acceptAllFeatures() {
|
||||||
if (!selectedFeature) return;
|
if (selectedFeatures.length === 0) return;
|
||||||
|
|
||||||
|
selectedFeatures.forEach((feature, index) => {
|
||||||
// Remove from rejected if present
|
// Remove from rejected if present
|
||||||
rejectedFeatures.delete(selectedFeature);
|
rejectedFeatures.delete(feature);
|
||||||
|
|
||||||
// Add to accepted
|
// Add to accepted
|
||||||
acceptedFeatures.add(selectedFeature);
|
acceptedFeatures.add(feature);
|
||||||
|
|
||||||
// Update layer style
|
// Update layer style
|
||||||
if (selectedLayer) {
|
const layerInfo = selectedLayers[index];
|
||||||
const isPoint = selectedFeature.geometry.type === 'Point';
|
if (layerInfo && layerInfo.layer) {
|
||||||
|
const isPoint = feature.geometry.type === 'Point';
|
||||||
if (isPoint) {
|
if (isPoint) {
|
||||||
selectedLayer.setStyle(diffMarkerStyle(selectedFeature));
|
layerInfo.layer.setStyle(diffMarkerStyle(feature));
|
||||||
} else {
|
} else {
|
||||||
selectedLayer.setStyle(diffStyle(selectedFeature));
|
layerInfo.layer.setStyle(diffStyle(feature));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Close popup
|
// Close popup
|
||||||
if (featurePopup) {
|
if (featurePopup) {
|
||||||
map.closePopup(featurePopup);
|
map.closePopup(featurePopup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
clearSelection();
|
||||||
|
|
||||||
// Enable save button
|
// Enable save button
|
||||||
updateSaveButton();
|
updateSaveButton();
|
||||||
|
|
||||||
showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
|
showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject a feature
|
// Reject all selected features
|
||||||
function rejectFeature() {
|
function rejectAllFeatures() {
|
||||||
if (!selectedFeature) return;
|
if (selectedFeatures.length === 0) return;
|
||||||
|
|
||||||
|
selectedFeatures.forEach((feature, index) => {
|
||||||
// Remove from accepted if present
|
// Remove from accepted if present
|
||||||
acceptedFeatures.delete(selectedFeature);
|
acceptedFeatures.delete(feature);
|
||||||
|
|
||||||
// Add to rejected
|
// Add to rejected
|
||||||
rejectedFeatures.add(selectedFeature);
|
rejectedFeatures.add(feature);
|
||||||
|
|
||||||
// Update layer style
|
// Update layer style
|
||||||
if (selectedLayer) {
|
const layerInfo = selectedLayers[index];
|
||||||
const isPoint = selectedFeature.geometry.type === 'Point';
|
if (layerInfo && layerInfo.layer) {
|
||||||
|
const isPoint = feature.geometry.type === 'Point';
|
||||||
if (isPoint) {
|
if (isPoint) {
|
||||||
selectedLayer.setStyle(diffMarkerStyle(selectedFeature));
|
layerInfo.layer.setStyle(diffMarkerStyle(feature));
|
||||||
} else {
|
} else {
|
||||||
selectedLayer.setStyle(diffStyle(selectedFeature));
|
layerInfo.layer.setStyle(diffStyle(feature));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Close popup
|
// Close popup
|
||||||
if (featurePopup) {
|
if (featurePopup) {
|
||||||
map.closePopup(featurePopup);
|
map.closePopup(featurePopup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
clearSelection();
|
||||||
|
|
||||||
// Enable save button
|
// Enable save button
|
||||||
updateSaveButton();
|
updateSaveButton();
|
||||||
|
|
||||||
@@ -586,8 +878,8 @@ function rejectFeature() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expose functions globally for onclick handlers
|
// Expose functions globally for onclick handlers
|
||||||
window.acceptFeature = acceptFeature;
|
window.acceptAllFeatures = acceptAllFeatures;
|
||||||
window.rejectFeature = rejectFeature;
|
window.rejectAllFeatures = rejectAllFeatures;
|
||||||
|
|
||||||
// Update save button state
|
// Update save button state
|
||||||
function updateSaveButton() {
|
function updateSaveButton() {
|
||||||
@@ -616,7 +908,7 @@ async function loadFiles() {
|
|||||||
const dataType = document.getElementById('dataTypeSelect').value;
|
const dataType = document.getElementById('dataTypeSelect').value;
|
||||||
|
|
||||||
// Build file paths based on county and data type
|
// Build file paths based on county and data type
|
||||||
let osmFile, diffFile, countyFile;
|
let osmFile, diffFile, countyFile, diffAddedFile, diffRemovedFile;
|
||||||
|
|
||||||
if (dataType === 'roads') {
|
if (dataType === 'roads') {
|
||||||
osmFile = `latest/${county}/osm-roads.geojson`;
|
osmFile = `latest/${county}/osm-roads.geojson`;
|
||||||
@@ -627,16 +919,55 @@ async function loadFiles() {
|
|||||||
diffFile = `latest/${county}/diff-paths.geojson`;
|
diffFile = `latest/${county}/diff-paths.geojson`;
|
||||||
countyFile = `latest/${county}/county-paths.geojson`;
|
countyFile = `latest/${county}/county-paths.geojson`;
|
||||||
} else if (dataType === 'addresses') {
|
} else if (dataType === 'addresses') {
|
||||||
osmFile = `osm_cache/osm_addresses_${county}_20251207.geojson`;
|
osmFile = `latest/${county}/osm-addresses.geojson`;
|
||||||
diffFile = `latest/${county}/addresses-to-add.geojson`;
|
diffAddedFile = `latest/${county}/addresses-to-add.geojson`;
|
||||||
|
diffRemovedFile = `latest/${county}/addresses-potentially-removed.geojson`;
|
||||||
countyFile = `latest/${county}/addresses.shp_converted.geojson`;
|
countyFile = `latest/${county}/addresses.shp_converted.geojson`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load files from server
|
// Load files from server
|
||||||
osmData = osmFile ? await loadFromServer(`/data/${osmFile}`) : null;
|
osmData = osmFile ? await loadFromServer(`/data/${osmFile}`) : null;
|
||||||
diffData = diffFile ? await loadFromServer(`/data/${diffFile}`) : null;
|
|
||||||
countyData = countyFile ? await loadFromServer(`/data/${countyFile}`) : 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) {
|
if (!osmData && !diffData && !countyData) {
|
||||||
showStatus(`No data files found for ${county} ${dataType}. Run the processing scripts first.`, 'error');
|
showStatus(`No data files found for ${county} ${dataType}. Run the processing scripts first.`, 'error');
|
||||||
return;
|
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() {
|
async function saveAcceptedItems() {
|
||||||
if (!diffData || (acceptedFeatures.size === 0 && rejectedFeatures.size === 0)) {
|
if (!diffData || acceptedFeatures.size === 0) {
|
||||||
showStatus('No features to save', 'error');
|
showStatus('No accepted features to save', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 => {
|
diffData.features.forEach(feature => {
|
||||||
if (acceptedFeatures.has(feature)) {
|
if (acceptedFeatures.has(feature)) {
|
||||||
feature.properties.accepted = true;
|
// Clone feature and remove status/approved/removed properties
|
||||||
} else if (rejectedFeatures.has(feature)) {
|
const cleanFeature = {
|
||||||
feature.properties.accepted = false;
|
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];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create download
|
// Determine if added or removed
|
||||||
const dataStr = JSON.stringify(diffData, null, 2);
|
const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True');
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
if (isRemoved) {
|
||||||
const url = URL.createObjectURL(dataBlob);
|
acceptedRemoved.push(cleanFeature);
|
||||||
|
} else {
|
||||||
|
acceptedAdded.push(cleanFeature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const link = document.createElement('a');
|
// Create and download added-approved.geojson
|
||||||
link.href = url;
|
if (acceptedAdded.length > 0) {
|
||||||
link.download = 'diff-updated.geojson';
|
const addedData = {
|
||||||
document.body.appendChild(link);
|
type: 'FeatureCollection',
|
||||||
link.click();
|
features: acceptedAdded
|
||||||
document.body.removeChild(link);
|
};
|
||||||
URL.revokeObjectURL(url);
|
const addedStr = JSON.stringify(addedData, null, 2);
|
||||||
|
const addedBlob = new Blob([addedStr], { type: 'application/json' });
|
||||||
|
const addedUrl = URL.createObjectURL(addedBlob);
|
||||||
|
|
||||||
showStatus(`Saved ${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
} catch (error) {
|
||||||
showStatus(`Save failed: ${error.message}`, 'error');
|
showStatus(`Save failed: ${error.message}`, 'error');
|
||||||
@@ -753,6 +1130,33 @@ function toggleLayer(layerId, layer) {
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initMap();
|
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
|
// Layer toggles
|
||||||
document.getElementById('osmToggle').addEventListener('change', function() {
|
document.getElementById('osmToggle').addEventListener('change', function() {
|
||||||
toggleLayer('osmToggle', osmLayer);
|
toggleLayer('osmToggle', osmLayer);
|
||||||
@@ -782,11 +1186,32 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load button
|
// 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
|
// Save button
|
||||||
document.getElementById('saveButton').addEventListener('click', saveAcceptedItems);
|
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
|
// Drag and drop for layer reordering
|
||||||
const layerList = document.getElementById('layerList');
|
const layerList = document.getElementById('layerList');
|
||||||
const layerItems = layerList.querySelectorAll('.layer-item');
|
const layerItems = layerList.querySelectorAll('.layer-item');
|
||||||
|
|||||||
+109
-1
@@ -164,6 +164,47 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
font-size: 16px;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -198,14 +239,30 @@
|
|||||||
{# County-specific commands #}
|
{# County-specific commands #}
|
||||||
<div class="button-grid">
|
<div class="button-grid">
|
||||||
{% if 'lake' in script_config %}
|
{% if 'lake' in script_config %}
|
||||||
|
<div class="button-with-checkbox">
|
||||||
<button class="script-button lake" onclick="runScript('{{ script_name }}', 'lake')">
|
<button class="script-button lake" onclick="runScript('{{ script_name }}', 'lake')">
|
||||||
{{ script_name|replace('-', ' ')|title }} (Lake)
|
{{ script_name|replace('-', ' ')|title }} (Lake)
|
||||||
</button>
|
</button>
|
||||||
|
{% if script_name == 'diff-addresses' %}
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="force-redownload-lake">
|
||||||
|
Force OSM Redownload
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if 'sumter' in script_config %}
|
{% if 'sumter' in script_config %}
|
||||||
|
<div class="button-with-checkbox">
|
||||||
<button class="script-button sumter" onclick="runScript('{{ script_name }}', 'sumter')">
|
<button class="script-button sumter" onclick="runScript('{{ script_name }}', 'sumter')">
|
||||||
{{ script_name|replace('-', ' ')|title }} (Sumter)
|
{{ script_name|replace('-', ' ')|title }} (Sumter)
|
||||||
</button>
|
</button>
|
||||||
|
{% if script_name == 'diff-addresses' %}
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="force-redownload-sumter">
|
||||||
|
Force OSM Redownload
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -243,6 +300,7 @@
|
|||||||
<div class="log-viewer">
|
<div class="log-viewer">
|
||||||
<h2>Script Output</h2>
|
<h2>Script Output</h2>
|
||||||
<div id="status" class="status-message"></div>
|
<div id="status" class="status-message"></div>
|
||||||
|
<button id="cancelButton" class="cancel-button" onclick="cancelJob()">Cancel Job</button>
|
||||||
<div id="logs" class="log-box"></div>
|
<div id="logs" class="log-box"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,6 +327,19 @@
|
|||||||
btn.disabled = true;
|
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', {
|
fetch('/api/run-script', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -276,7 +347,8 @@
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
script: scriptName,
|
script: scriptName,
|
||||||
county: county
|
county: county,
|
||||||
|
forceDownload: forceDownload
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
@@ -284,6 +356,7 @@
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
showStatus(`Error: ${data.error}`, 'error');
|
showStatus(`Error: ${data.error}`, 'error');
|
||||||
enableButtons();
|
enableButtons();
|
||||||
|
document.getElementById('cancelButton').classList.remove('active');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +373,40 @@
|
|||||||
.catch(error => {
|
.catch(error => {
|
||||||
showStatus(`Error: ${error.message}`, 'error');
|
showStatus(`Error: ${error.message}`, 'error');
|
||||||
enableButtons();
|
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);
|
clearInterval(logCheckInterval);
|
||||||
showStatus('Script completed', 'success');
|
showStatus('Script completed', 'success');
|
||||||
enableButtons();
|
enableButtons();
|
||||||
|
document.getElementById('cancelButton').classList.remove('active');
|
||||||
currentJobId = null;
|
currentJobId = null;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+140
-1
@@ -149,12 +149,143 @@
|
|||||||
.load-button:hover {
|
.load-button:hover {
|
||||||
background: #218838 !important;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="map"></div>
|
<div id="map"></div>
|
||||||
|
|
||||||
<div class="controls">
|
<!-- Hamburger menu button for mobile -->
|
||||||
|
<button class="hamburger-button" id="hamburgerButton" aria-label="Toggle menu">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Overlay for mobile -->
|
||||||
|
<div class="overlay" id="overlay"></div>
|
||||||
|
|
||||||
|
<div class="controls" id="controls">
|
||||||
|
<button class="close-button" id="closeButton" aria-label="Close menu">×</button>
|
||||||
<h3>Layer Controls (top to bottom)</h3>
|
<h3>Layer Controls (top to bottom)</h3>
|
||||||
<div id="layerList">
|
<div id="layerList">
|
||||||
<div class="layer-item" draggable="true" data-layer="diff">
|
<div class="layer-item" draggable="true" data-layer="diff">
|
||||||
@@ -185,6 +316,14 @@
|
|||||||
Hide highway=service
|
Hide highway=service
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 15px;">Selection Mode</h3>
|
||||||
|
<button id="multiSelectToggle" style="width: 100%; padding: 8px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px;">
|
||||||
|
Multi-Select: OFF
|
||||||
|
</button>
|
||||||
|
<div style="margin-top: 8px; padding: 8px; background: #e7f3ff; border-radius: 4px; font-size: 11px; color: #004085;">
|
||||||
|
<strong>Tip:</strong> Enable multi-select or hold Shift to select multiple features by clicking or dragging a box
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 style="margin-top: 15px;">Load Data</h3>
|
<h3 style="margin-top: 15px;">Load Data</h3>
|
||||||
<div class="file-input-group">
|
<div class="file-input-group">
|
||||||
<label for="countySelect">County:</label>
|
<label for="countySelect">County:</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user