add a bunch of (false) information to the map
This commit is contained in:
@@ -34,6 +34,7 @@ let
|
||||
pkgs.python3Packages.scipy
|
||||
pkgs.python3Packages.numpy
|
||||
pkgs.python3Packages.pyproj
|
||||
pkgs.python3Packages.requests
|
||||
];
|
||||
|
||||
mkShell = pkgs.mkShell;
|
||||
|
||||
240
tooling/process_hopewell_sites.py
Normal file
240
tooling/process_hopewell_sites.py
Normal file
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Process Hopewell Earthworks Sites
|
||||
Acquires lidar tiles, processes them to mound format, and generates metadata.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
# Define all sites of interest
|
||||
SITES = [
|
||||
{
|
||||
"name": "Newark Octagon Earthworks",
|
||||
"coordinates": [(-82.4463745, 40.0519828)],
|
||||
"description": "Part of the Newark Earthworks complex, the Octagon is precisely aligned to the 18.6-year lunar cycle. Connected to a 50-acre circle, this geometric earthwork demonstrates sophisticated astronomical knowledge. The Octagon's eight walls and Observatory Circle form one of only two known circle-octagon pairs in the Hopewell world.",
|
||||
"type": "earthwork",
|
||||
"significance": "unesco_world_heritage",
|
||||
"azimuth_alignment": 211,
|
||||
},
|
||||
{
|
||||
"name": "Great Circle Earthworks",
|
||||
"coordinates": [(-82.4277555, 40.0402671)],
|
||||
"description": "A nearly perfect circle 1,200 feet in diameter, enclosing 30 acres. The earthen wall is lined by a deep interior ditch. Located in Heath, Ohio, this is one of the largest circular earthworks in the Americas and serves as the site of the Newark Earthworks Museum.",
|
||||
"type": "earthwork",
|
||||
"significance": "unesco_world_heritage",
|
||||
},
|
||||
{
|
||||
"name": "Van Voorhis Walls",
|
||||
"coordinates": [
|
||||
(-82.446375, 40.051983),
|
||||
(-82.447, 40.048),
|
||||
(-82.448, 40.045),
|
||||
(-82.450, 40.040),
|
||||
],
|
||||
"description": "The best-preserved section of the Great Hopewell Road, extending 2.5 miles south from the Newark Octagon to Ramp Creek. This confirmed earthwork consists of parallel walls approximately 50 meters (150-200 feet) apart, aligned on an azimuth of 211°. Still visible above ground in woodland areas too swampy to farm.",
|
||||
"type": "road_confirmed",
|
||||
"significance": "confirmed_road_section",
|
||||
"azimuth_alignment": 211,
|
||||
},
|
||||
{
|
||||
"name": "Mound City Group",
|
||||
"coordinates": [(-83.0065767, 39.3744923)],
|
||||
"description": "The headquarters of Hopewell Culture National Historical Park. Contains 23 burial mounds within a nearly square earthen enclosure along the Scioto River. Each mound covered a charnel house where the dead were cremated. Excavations revealed spectacular artifacts including effigy pipes, mica, and copper.",
|
||||
"type": "earthwork",
|
||||
"significance": "unesco_world_heritage",
|
||||
},
|
||||
{
|
||||
"name": "Hopeton Earthworks",
|
||||
"coordinates": [(-82.9809185, 39.3790743)],
|
||||
"description": "A geometric earthwork complex featuring a circle (320m diameter) and square of similar size, connected by parallel earthen lines aligned to the winter solstice. Located on a high terrace on the east side of the Scioto River, northeast from Mound City.",
|
||||
"type": "earthwork",
|
||||
"significance": "unesco_world_heritage",
|
||||
"astronomical_alignment": "winter_solstice",
|
||||
},
|
||||
{
|
||||
"name": "Hopewell Mound Group",
|
||||
"coordinates": [(-83.0844809, 39.3608166)],
|
||||
"description": "The type site for the Hopewell culture, named after former landowner M. Cloud Hopewell. Contains 29 mounds including the largest known Hopewell mound—500 feet long and consisting of three conjoined circles. A semicircular earthwork encloses the main mound and four additional mounds.",
|
||||
"type": "earthwork",
|
||||
"significance": "type_site",
|
||||
},
|
||||
{
|
||||
"name": "Seip Earthworks",
|
||||
"coordinates": [(-83.2214086, 39.2416867)],
|
||||
"description": "One of the largest Hopewell complexes, featuring two circles and a square enclosing 121 acres. The Seip-Pricer Mound stands 30 feet high. The square measures exactly 27 acres, matching four other nearby Hopewell sites, suggesting a common unit of measurement. Contains evidence of elaborate burials and exotic trade goods.",
|
||||
"type": "earthwork",
|
||||
"significance": "unesco_world_heritage",
|
||||
},
|
||||
{
|
||||
"name": "High Bank Works",
|
||||
"coordinates": [(-82.94, 39.26)],
|
||||
"description": "Features a circle-octagon pair identical to the Newark Earthworks—both circles are exactly 1,050 feet in diameter. Located 60 miles from Newark on a terrace 75-80 feet above the Scioto River. The octagon is precisely aligned to the northernmost moonrise of the 18.6-year lunar cycle. Currently a research preserve, not open to the public.",
|
||||
"type": "earthwork",
|
||||
"significance": "unesco_world_heritage",
|
||||
"astronomical_alignment": "lunar_standstill",
|
||||
"paired_with": "Newark Octagon Earthworks",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def run_command(cmd: List[str], description: str) -> subprocess.CompletedProcess:
|
||||
"""Run a shell command and return the result."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"{description}")
|
||||
print(f"{'='*60}")
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"ERROR: {result.stderr}")
|
||||
else:
|
||||
print(f"SUCCESS: {result.stdout}")
|
||||
return result
|
||||
|
||||
|
||||
def acquire_tiles(site: Dict, output_dir: str) -> List[str]:
|
||||
"""Acquire lidar tiles for a site."""
|
||||
coords = site["coordinates"]
|
||||
|
||||
# Build the request_tiles.py command
|
||||
cmd = ["python", "./tooling/request_tiles.py"]
|
||||
for lon, lat in coords:
|
||||
cmd.extend(["--coords", str(lon), str(lat)])
|
||||
cmd.extend(["--output", output_dir])
|
||||
|
||||
result = run_command(cmd, f"Acquiring tiles for {site['name']}")
|
||||
|
||||
# Parse the output JSON to get tile names
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
tiles = [
|
||||
tile["tile_name"]
|
||||
for tile in data.get("tiles", [])
|
||||
if tile.get("status") in ["downloaded", "already_exists"]
|
||||
]
|
||||
return tiles
|
||||
except json.JSONDecodeError:
|
||||
print(f"Warning: Could not parse JSON output for {site['name']}")
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def unzip_tiles(tiles: List[str], lidar_dir: str) -> None:
|
||||
"""Unzip all downloaded tiles."""
|
||||
for tile in tiles:
|
||||
zip_path = os.path.join(lidar_dir, f"{tile}.zip")
|
||||
extract_dir = os.path.join(lidar_dir, tile)
|
||||
|
||||
if not os.path.exists(zip_path):
|
||||
print(f"Warning: {zip_path} does not exist, skipping")
|
||||
continue
|
||||
|
||||
cmd = ["unzip", "-o", zip_path, "-d", extract_dir]
|
||||
run_command(cmd, f"Unzipping {tile}")
|
||||
|
||||
|
||||
def convert_to_mound(tiles: List[str], lidar_dir: str, mound_dir: str) -> None:
|
||||
"""Convert LAS files to mound format."""
|
||||
for tile in tiles:
|
||||
las_path = os.path.join(lidar_dir, tile, f"{tile}.las")
|
||||
mound_path = os.path.join(mound_dir, f"{tile}.mound")
|
||||
|
||||
if not os.path.exists(las_path):
|
||||
print(f"Warning: {las_path} does not exist, skipping")
|
||||
continue
|
||||
|
||||
cmd = ["python", "tooling/las2mound.py", las_path, mound_path]
|
||||
run_command(cmd, f"Converting {tile} to mound format")
|
||||
|
||||
|
||||
def generate_metadata(sites: List[Dict], output_path: str) -> None:
|
||||
"""Generate a JSON metadata file with all site information."""
|
||||
metadata = {
|
||||
"generated": "2025-01-21",
|
||||
"description": "Hopewell Ceremonial Earthworks and Great Hopewell Road Sites",
|
||||
"sites": sites,
|
||||
"summary": {
|
||||
"total_sites": len(sites),
|
||||
"unesco_sites": len([s for s in sites if s.get("significance") == "unesco_world_heritage"]),
|
||||
"confirmed_road_sections": len([s for s in sites if s.get("type") == "road_confirmed"]),
|
||||
}
|
||||
}
|
||||
|
||||
with open(output_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Metadata written to {output_path}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main processing pipeline."""
|
||||
# Setup directories
|
||||
base_dir = Path("./data")
|
||||
lidar_dir = base_dir / "LIDAR"
|
||||
mound_dir = base_dir / "MOUND"
|
||||
|
||||
lidar_dir.mkdir(parents=True, exist_ok=True)
|
||||
mound_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"""
|
||||
╔══════════════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ HOPEWELL EARTHWORKS LIDAR PROCESSING PIPELINE ║
|
||||
║ ║
|
||||
║ Processing {len(SITES)} sites of interest: ║
|
||||
║ - UNESCO World Heritage Sites ║
|
||||
║ - Confirmed Great Hopewell Road sections ║
|
||||
║ - Type sites and major earthwork complexes ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════════════════════════╝
|
||||
""")
|
||||
|
||||
# Process each site
|
||||
for site in SITES:
|
||||
print(f"\n{'#'*80}")
|
||||
print(f"# PROCESSING: {site['name']}")
|
||||
print(f"# Type: {site['type']}")
|
||||
print(f"# Coordinates: {len(site['coordinates'])} point(s)")
|
||||
print(f"{'#'*80}")
|
||||
|
||||
# Step 1: Acquire tiles
|
||||
tiles = acquire_tiles(site, str(lidar_dir))
|
||||
if not tiles:
|
||||
print(f"Warning: No tiles acquired for {site['name']}")
|
||||
site["tiles"] = []
|
||||
continue
|
||||
|
||||
site["tiles"] = tiles
|
||||
print(f"\nAcquired {len(tiles)} tiles: {', '.join(tiles)}")
|
||||
|
||||
# Step 2: Unzip tiles
|
||||
unzip_tiles(tiles, str(lidar_dir))
|
||||
|
||||
# Step 3: Convert to mound format
|
||||
convert_to_mound(tiles, str(lidar_dir), str(mound_dir))
|
||||
|
||||
# Step 4: Generate metadata
|
||||
metadata_path = base_dir / "hopewell_sites_metadata.json"
|
||||
generate_metadata(SITES, str(metadata_path))
|
||||
|
||||
print(f"""
|
||||
╔══════════════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ PROCESSING COMPLETE ║
|
||||
║ ║
|
||||
║ Metadata: {str(metadata_path):55} ║
|
||||
║ Lidar data: {str(lidar_dir):58} ║
|
||||
║ Mound files: {str(mound_dir):57} ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════════════════════════╝
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
401
tooling/request_tiles.py
Normal file
401
tooling/request_tiles.py
Normal file
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Request and download Ohio lidar tiles based on coordinates.
|
||||
|
||||
Usage:
|
||||
./request_tiles.py --coords -82.4438 40.0547 --coords -82.4440 40.0548 --output tiles/
|
||||
echo "-82.4438,40.0547" | ./request_tiles.py --output tiles/
|
||||
./request_tiles.py --coords-file locations.txt --output tiles/
|
||||
|
||||
This tool:
|
||||
1. Converts lon/lat coordinates to Web Mercator (EPSG:3857)
|
||||
2. Queries Ohio's ArcGIS tile service to get tile metadata
|
||||
3. Downloads tiles from OGRIP as .zip files
|
||||
4. Outputs JSON summary of downloaded tiles to stdout
|
||||
5. Respects rate limiting between requests
|
||||
|
||||
Output format (JSON to stdout):
|
||||
{
|
||||
"requested": 5,
|
||||
"downloaded": 4,
|
||||
"skipped": 1,
|
||||
"failed": 0,
|
||||
"tiles": [
|
||||
{
|
||||
"tile_name": "BS19820747",
|
||||
"county": "LIC",
|
||||
"year": "2020",
|
||||
"block": "4",
|
||||
"url": "https://gis1.oit.ohio.gov/...",
|
||||
"output_path": "tiles/BS19820747.zip",
|
||||
"status": "downloaded"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import argparse
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional, Dict, Any
|
||||
|
||||
try:
|
||||
from pyproj import Transformer
|
||||
except ImportError:
|
||||
print("Error: pyproj not installed. Run: pip install pyproj", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Configure logging to stderr only (stdout is for JSON output)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
stream=sys.stderr
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ohio ArcGIS tile service
|
||||
TILE_SERVICE_URL = "https://maps.ohio.gov/arcgis/rest/services/OGRIP/3DepTiles/MapServer/0/query"
|
||||
|
||||
# OGRIP download URL template
|
||||
DOWNLOAD_URL_TEMPLATE = "https://gis1.oit.ohio.gov/ZIPARCHIVES_III/ELEVATION/3DEP/LIDAR/{county}/{tile_name}.zip"
|
||||
|
||||
|
||||
def lonlat_to_webmercator(lon: float, lat: float) -> Tuple[float, float]:
|
||||
"""Convert lon/lat (WGS84) to Web Mercator (EPSG:3857)."""
|
||||
transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
|
||||
x, y = transformer.transform(lon, lat)
|
||||
return x, y
|
||||
|
||||
|
||||
def query_tile_info(x: float, y: float) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Query Ohio ArcGIS service for tile information at given Web Mercator coordinates.
|
||||
|
||||
Returns dict with keys: tile_name, county, year, block, note
|
||||
Returns None if no tile found or on error.
|
||||
"""
|
||||
params = {
|
||||
'f': 'json',
|
||||
'returnGeometry': 'false',
|
||||
'spatialRel': 'esriSpatialRelIntersects',
|
||||
'geometry': json.dumps({
|
||||
'x': x,
|
||||
'y': y,
|
||||
'spatialReference': {'wkid': 3857}
|
||||
}),
|
||||
'geometryType': 'esriGeometryPoint',
|
||||
'inSR': '3857',
|
||||
'outFields': '*',
|
||||
'outSR': '3857'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(TILE_SERVICE_URL, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if 'features' not in data or len(data['features']) == 0:
|
||||
return None
|
||||
|
||||
# Get first feature (should only be one)
|
||||
attrs = data['features'][0]['attributes']
|
||||
|
||||
return {
|
||||
'tile_name': attrs.get('TileName'),
|
||||
'county': attrs.get('County'),
|
||||
'year': attrs.get('Year'),
|
||||
'block': attrs.get('Block'),
|
||||
'note': attrs.get('note')
|
||||
}
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to query tile service: {e}")
|
||||
return None
|
||||
except (KeyError, json.JSONDecodeError) as e:
|
||||
logger.error(f"Failed to parse tile service response: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def download_tile(tile_name: str, county: str, output_dir: Path) -> Tuple[bool, Optional[Path]]:
|
||||
"""
|
||||
Download a tile zip file from OGRIP.
|
||||
|
||||
Returns (success, output_path)
|
||||
"""
|
||||
url = DOWNLOAD_URL_TEMPLATE.format(county=county, tile_name=tile_name)
|
||||
output_path = output_dir / f"{tile_name}.zip"
|
||||
|
||||
# Check if already exists
|
||||
if output_path.exists():
|
||||
logger.info(f"Tile {tile_name} already exists, skipping download")
|
||||
return True, output_path
|
||||
|
||||
try:
|
||||
logger.info(f"Downloading {tile_name} from {county}...")
|
||||
response = requests.get(url, timeout=30, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
# Download with progress
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
size_mb = output_path.stat().st_size / (1024 * 1024)
|
||||
logger.info(f"Downloaded {tile_name} ({size_mb:.2f} MB)")
|
||||
return True, output_path
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to download {tile_name}: {e}")
|
||||
# Clean up partial download
|
||||
if output_path.exists():
|
||||
output_path.unlink()
|
||||
return False, None
|
||||
|
||||
|
||||
def process_coordinates(
|
||||
coords: List[Tuple[float, float]],
|
||||
output_dir: Path,
|
||||
min_delay: float
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a list of coordinates and download corresponding tiles.
|
||||
|
||||
Returns summary dict with download results.
|
||||
"""
|
||||
results = {
|
||||
'requested': len(coords),
|
||||
'downloaded': 0,
|
||||
'skipped': 0,
|
||||
'failed': 0,
|
||||
'tiles': []
|
||||
}
|
||||
|
||||
# Track unique tiles to avoid duplicate downloads
|
||||
seen_tiles = set()
|
||||
|
||||
for i, (lon, lat) in enumerate(coords):
|
||||
logger.info(f"Processing coordinate {i+1}/{len(coords)}: ({lon:.6f}, {lat:.6f})")
|
||||
|
||||
# Convert to Web Mercator
|
||||
x, y = lonlat_to_webmercator(lon, lat)
|
||||
logger.debug(f"Web Mercator: ({x:.2f}, {y:.2f})")
|
||||
|
||||
# Query tile info
|
||||
tile_info = query_tile_info(x, y)
|
||||
|
||||
if not tile_info:
|
||||
logger.warning(f"No tile found for coordinates ({lon:.6f}, {lat:.6f})")
|
||||
results['failed'] += 1
|
||||
results['tiles'].append({
|
||||
'lon': lon,
|
||||
'lat': lat,
|
||||
'status': 'not_found'
|
||||
})
|
||||
continue
|
||||
|
||||
tile_name = tile_info['tile_name']
|
||||
county = tile_info['county']
|
||||
|
||||
# Check if we've already processed this tile
|
||||
if tile_name in seen_tiles:
|
||||
logger.info(f"Tile {tile_name} already processed in this run, skipping")
|
||||
results['skipped'] += 1
|
||||
continue
|
||||
|
||||
seen_tiles.add(tile_name)
|
||||
|
||||
# Download tile
|
||||
output_path = output_dir / f"{tile_name}.zip"
|
||||
|
||||
if output_path.exists():
|
||||
logger.info(f"Tile {tile_name} already exists on disk")
|
||||
results['skipped'] += 1
|
||||
status = 'already_exists'
|
||||
success = True
|
||||
else:
|
||||
success, download_path = download_tile(tile_name, county, output_dir)
|
||||
|
||||
if success:
|
||||
results['downloaded'] += 1
|
||||
status = 'downloaded'
|
||||
else:
|
||||
results['failed'] += 1
|
||||
status = 'download_failed'
|
||||
|
||||
# Add to results
|
||||
tile_result = {
|
||||
'tile_name': tile_name,
|
||||
'county': county,
|
||||
'year': tile_info.get('year'),
|
||||
'block': tile_info.get('block'),
|
||||
'url': DOWNLOAD_URL_TEMPLATE.format(county=county, tile_name=tile_name),
|
||||
'output_path': str(output_path) if success else None,
|
||||
'status': status
|
||||
}
|
||||
results['tiles'].append(tile_result)
|
||||
|
||||
# Rate limiting between downloads (skip if this was already on disk)
|
||||
if status == 'downloaded' and i < len(coords) - 1:
|
||||
logger.debug(f"Waiting {min_delay}s before next request...")
|
||||
time.sleep(min_delay)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def parse_coords_from_stdin() -> List[Tuple[float, float]]:
|
||||
"""Parse coordinates from stdin (one per line, comma or space separated)."""
|
||||
coords = []
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Try comma separator first, then space
|
||||
parts = line.replace(',', ' ').split()
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
lon = float(parts[0])
|
||||
lat = float(parts[1])
|
||||
coords.append((lon, lat))
|
||||
except ValueError:
|
||||
logger.warning(f"Skipping invalid line: {line}")
|
||||
|
||||
return coords
|
||||
|
||||
|
||||
def parse_coords_from_file(filepath: Path) -> List[Tuple[float, float]]:
|
||||
"""Parse coordinates from a file."""
|
||||
coords = []
|
||||
with open(filepath, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
parts = line.replace(',', ' ').split()
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
lon = float(parts[0])
|
||||
lat = float(parts[1])
|
||||
coords.append((lon, lat))
|
||||
except ValueError:
|
||||
logger.warning(f"Skipping invalid line: {line}")
|
||||
|
||||
return coords
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Download Ohio lidar tiles based on coordinates',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Download tiles for specific coordinates
|
||||
%(prog)s --coords -82.4438 40.0547 --coords -82.4440 40.0548 --output tiles/
|
||||
|
||||
# Read coordinates from stdin
|
||||
echo "-82.4438,40.0547" | %(prog)s --output tiles/
|
||||
|
||||
# Read from file
|
||||
%(prog)s --coords-file locations.txt --output tiles/
|
||||
|
||||
# Custom rate limiting
|
||||
%(prog)s --coords -82.4438 40.0547 --output tiles/ --delay 2.0
|
||||
|
||||
Coordinate format:
|
||||
Longitude, Latitude in decimal degrees (WGS84)
|
||||
Example: -82.4438 40.0547 (Newark Earthworks area)
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--coords',
|
||||
nargs=2,
|
||||
type=float,
|
||||
action='append',
|
||||
metavar=('LON', 'LAT'),
|
||||
help='Coordinate pair (longitude latitude). Can be specified multiple times.'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--coords-file',
|
||||
type=Path,
|
||||
metavar='FILE',
|
||||
help='File containing coordinates (one pair per line, comma or space separated)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
type=Path,
|
||||
required=True,
|
||||
metavar='DIR',
|
||||
help='Output directory for downloaded tile zip files'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--delay',
|
||||
type=float,
|
||||
default=1.0,
|
||||
metavar='SECONDS',
|
||||
help='Minimum delay between tile downloads (default: 1.0 seconds)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
help='Enable verbose debug logging'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Collect coordinates from all sources
|
||||
coords = []
|
||||
|
||||
if args.coords:
|
||||
coords.extend(args.coords)
|
||||
|
||||
if args.coords_file:
|
||||
if not args.coords_file.exists():
|
||||
logger.error(f"Coordinates file not found: {args.coords_file}")
|
||||
sys.exit(1)
|
||||
coords.extend(parse_coords_from_file(args.coords_file))
|
||||
|
||||
# Check stdin if no coordinates provided
|
||||
if not coords and not sys.stdin.isatty():
|
||||
coords.extend(parse_coords_from_stdin())
|
||||
|
||||
if not coords:
|
||||
logger.error("No coordinates provided. Use --coords, --coords-file, or pipe to stdin.")
|
||||
parser.print_help(sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(f"Processing {len(coords)} coordinate(s)")
|
||||
logger.info(f"Output directory: {args.output}")
|
||||
logger.info(f"Rate limit: {args.delay}s between downloads")
|
||||
|
||||
# Process coordinates and download tiles
|
||||
results = process_coordinates(coords, args.output, args.delay)
|
||||
|
||||
# Output JSON to stdout
|
||||
print(json.dumps(results, indent=2))
|
||||
|
||||
# Log summary to stderr
|
||||
logger.info("="*50)
|
||||
logger.info(f"Summary: {results['downloaded']} downloaded, {results['skipped']} skipped, {results['failed']} failed")
|
||||
|
||||
if results['failed'] > 0:
|
||||
logger.error(f"{results['failed']} tile(s) failed to download or locate")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
387
ui/src/App.vue
387
ui/src/App.vue
@@ -20,10 +20,24 @@
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<label>
|
||||
<input type="checkbox" v-model="showOctagon" @change="toggleOctagon">
|
||||
Show Octagon
|
||||
<label class="section-header" @click="historicMarkersExpanded = !historicMarkersExpanded" style="cursor: pointer;">
|
||||
{{ historicMarkersExpanded ? '▼' : '▶' }} Historic Markers
|
||||
</label>
|
||||
<div v-if="historicMarkersExpanded" class="subsection">
|
||||
<button class="hide-all-btn" @click="hideAllHistoricMarkers">
|
||||
{{ allHistoricMarkersHidden ? 'Show All' : 'Hide All' }}
|
||||
</button>
|
||||
<div v-for="site in KNOWN_SITES" :key="site.name" class="site-control">
|
||||
<label>
|
||||
<input type="checkbox" v-model="visibleSites[site.name]" @change="toggleSite(site.name)">
|
||||
{{ site.name }}
|
||||
</label>
|
||||
<button class="jump-btn" @click="jumpToSite(site)" title="Jump to location">📍</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<label>
|
||||
<input type="checkbox" v-model="showLidar" @change="toggleLidar">
|
||||
Show Lidar
|
||||
@@ -111,6 +125,11 @@
|
||||
<div>Bearing: {{ popup.feature.properties.bearing.toFixed(1) }}°</div>
|
||||
<button @click="deleteFeature(popup.feature.id)" class="popup-btn danger">Delete</button>
|
||||
</div>
|
||||
<div v-else-if="popup.type === 'site'" class="site-popup">
|
||||
<strong>{{ popup.feature.properties.name }}</strong>
|
||||
<div class="site-type">{{ popup.feature.properties.type === 'road_confirmed' ? 'Confirmed Road Segment' : 'Earthwork' }}</div>
|
||||
<div class="site-description">{{ popup.feature.properties.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="popup.visible = false" class="popup-close">×</button>
|
||||
</div>
|
||||
@@ -138,7 +157,9 @@ const sandboxRef = ref(null);
|
||||
const sandboxVisible = ref(false);
|
||||
const sandboxOffscreen = ref(false);
|
||||
const baseLayer = ref('osm');
|
||||
const showOctagon = ref(true);
|
||||
const historicMarkersExpanded = ref(true);
|
||||
const visibleSites = ref({});
|
||||
const allHistoricMarkersHidden = ref(false);
|
||||
const showLidar = ref(true);
|
||||
const showGeometry = ref(true);
|
||||
const imperialUnits = ref(false);
|
||||
@@ -155,7 +176,7 @@ const nextFeatureId = ref(1);
|
||||
|
||||
// UI state
|
||||
const contextMenu = ref({ visible: false, x: 0, y: 0, lngLat: null, hasTile: false });
|
||||
const popup = ref({ visible: false, x: 0, y: 0, type: null, feature: null });
|
||||
const popup = ref({ visible: false, x: 0, y: 0, type: null, feature: null, lngLat: null });
|
||||
|
||||
// Map instance
|
||||
let map = null;
|
||||
@@ -237,29 +258,16 @@ const KNOWN_SITES = [
|
||||
"tiles":["BS18430458"]}
|
||||
];
|
||||
|
||||
// Tile names to load
|
||||
const TILE_NAMES = [
|
||||
'BS19820747',
|
||||
'BS19820748',
|
||||
'BS19830747',
|
||||
'BS19830748',
|
||||
'BS19820746',
|
||||
'BS19810747',
|
||||
'BS19810746',
|
||||
];
|
||||
|
||||
// Newark Octagon coordinates
|
||||
const octagonCoords = [
|
||||
[-82.44123, 40.05443],
|
||||
[-82.44260, 40.05309],
|
||||
[-82.44464, 40.05237],
|
||||
[-82.44631, 40.05342],
|
||||
[-82.44728, 40.05500],
|
||||
[-82.44589, 40.05633],
|
||||
[-82.44389, 40.05698],
|
||||
[-82.44216, 40.05595],
|
||||
[-82.44123, 40.05443],
|
||||
];
|
||||
// Tile names to load - merged from all KNOWN_SITES
|
||||
const TILE_NAMES = (() => {
|
||||
const allTiles = new Set();
|
||||
KNOWN_SITES.forEach(site => {
|
||||
if (site.tiles) {
|
||||
site.tiles.forEach(tile => allTiles.add(tile));
|
||||
}
|
||||
});
|
||||
return Array.from(allTiles);
|
||||
})();
|
||||
|
||||
const octagonCenter = [-82.44383, 40.05469];
|
||||
|
||||
@@ -521,12 +529,64 @@ function updateBaseLayer() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOctagon() {
|
||||
function toggleGeometry() {
|
||||
if (!map) return;
|
||||
|
||||
const visibility = showOctagon.value ? 'visible' : 'none';
|
||||
map.setLayoutProperty('octagon-fill', 'visibility', visibility);
|
||||
map.setLayoutProperty('octagon-outline', 'visibility', visibility);
|
||||
const visibility = showGeometry.value ? 'visible' : 'none';
|
||||
if (map.getLayer('geometry-pins')) {
|
||||
map.setLayoutProperty('geometry-pins', 'visibility', visibility);
|
||||
}
|
||||
if (map.getLayer('geometry-lines')) {
|
||||
map.setLayoutProperty('geometry-lines', 'visibility', visibility);
|
||||
}
|
||||
if (map.getLayer('geometry-labels')) {
|
||||
map.setLayoutProperty('geometry-labels', 'visibility', visibility);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSite(siteName) {
|
||||
if (!map) return;
|
||||
|
||||
const visible = visibleSites.value[siteName];
|
||||
const visibility = visible ? 'visible' : 'none';
|
||||
|
||||
// Toggle marker
|
||||
const markerLayerId = `site-marker-${siteName}`;
|
||||
if (map.getLayer(markerLayerId)) {
|
||||
map.setLayoutProperty(markerLayerId, 'visibility', visibility);
|
||||
}
|
||||
|
||||
// Toggle overlay geometry
|
||||
const overlayLayerId = `site-overlay-${siteName}`;
|
||||
if (map.getLayer(overlayLayerId)) {
|
||||
map.setLayoutProperty(overlayLayerId, 'visibility', visibility);
|
||||
}
|
||||
|
||||
// Toggle polyline
|
||||
const polylineLayerId = `site-polyline-${siteName}`;
|
||||
if (map.getLayer(polylineLayerId)) {
|
||||
map.setLayoutProperty(polylineLayerId, 'visibility', visibility);
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToSite(site) {
|
||||
if (!map) return;
|
||||
|
||||
const coords = site.coordinates[0];
|
||||
map.flyTo({
|
||||
center: coords,
|
||||
zoom: 15,
|
||||
duration: 1500
|
||||
});
|
||||
}
|
||||
|
||||
function hideAllHistoricMarkers() {
|
||||
allHistoricMarkersHidden.value = !allHistoricMarkersHidden.value;
|
||||
|
||||
KNOWN_SITES.forEach(site => {
|
||||
visibleSites.value[site.name] = !allHistoricMarkersHidden.value;
|
||||
toggleSite(site.name);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleLidar() {
|
||||
@@ -553,21 +613,6 @@ function updateLidarOpacity() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGeometry() {
|
||||
if (!map) return;
|
||||
|
||||
const visibility = showGeometry.value ? 'visible' : 'none';
|
||||
if (map.getLayer('geometry-pins')) {
|
||||
map.setLayoutProperty('geometry-pins', 'visibility', visibility);
|
||||
}
|
||||
if (map.getLayer('geometry-lines')) {
|
||||
map.setLayoutProperty('geometry-lines', 'visibility', visibility);
|
||||
}
|
||||
if (map.getLayer('geometry-labels')) {
|
||||
map.setLayoutProperty('geometry-labels', 'visibility', visibility);
|
||||
}
|
||||
}
|
||||
|
||||
function openSandbox() {
|
||||
if (Object.keys(tileCache.value).length > 0) {
|
||||
sandboxVisible.value = true;
|
||||
@@ -826,12 +871,17 @@ onMounted(() => {
|
||||
const featureData = geometryFeatures.value.features.find(f => f.id === feature.properties.id);
|
||||
|
||||
if (featureData) {
|
||||
const coords = featureData.geometry.type === 'Point'
|
||||
? featureData.geometry.coordinates
|
||||
: featureData.geometry.coordinates[0]; // Use first point of line
|
||||
|
||||
popup.value = {
|
||||
visible: true,
|
||||
x: e.point.x,
|
||||
y: e.point.y,
|
||||
type: featureData.properties.type,
|
||||
feature: featureData
|
||||
feature: featureData,
|
||||
lngLat: coords
|
||||
};
|
||||
}
|
||||
} else {
|
||||
@@ -870,37 +920,144 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
map.on('load', async () => {
|
||||
// Add octagon overlay
|
||||
map.addSource('octagon', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [octagonCoords]
|
||||
// Initialize visibility state for all sites
|
||||
KNOWN_SITES.forEach(site => {
|
||||
visibleSites.value[site.name] = true;
|
||||
});
|
||||
|
||||
// Add historic site markers and overlays
|
||||
KNOWN_SITES.forEach(site => {
|
||||
// Add marker
|
||||
const markerCoords = site.coordinates[0];
|
||||
map.addSource(`site-marker-${site.name}`, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: markerCoords
|
||||
},
|
||||
properties: {
|
||||
name: site.name,
|
||||
description: site.description,
|
||||
type: site.type
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: `site-marker-${site.name}`,
|
||||
type: 'circle',
|
||||
source: `site-marker-${site.name}`,
|
||||
paint: {
|
||||
'circle-radius': 10,
|
||||
'circle-color': site.type === 'road_confirmed' ? '#4A9EFF' : '#ff6b6b',
|
||||
'circle-stroke-width': 3,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
});
|
||||
|
||||
// Add overlay geometry if present
|
||||
if (site.overlay) {
|
||||
site.overlay.forEach((geom, idx) => {
|
||||
map.addSource(`site-overlay-${site.name}-${idx}`, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [geom.coordinates]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: `site-overlay-${site.name}-${idx}`,
|
||||
type: 'fill',
|
||||
source: `site-overlay-${site.name}-${idx}`,
|
||||
paint: {
|
||||
'fill-color': site.type === 'road_confirmed' ? '#4A9EFF' : '#ff6b6b',
|
||||
'fill-opacity': 0.2
|
||||
}
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: `site-overlay-outline-${site.name}-${idx}`,
|
||||
type: 'line',
|
||||
source: `site-overlay-${site.name}-${idx}`,
|
||||
paint: {
|
||||
'line-color': site.type === 'road_confirmed' ? '#4A9EFF' : '#ff6b6b',
|
||||
'line-width': 2
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: 'octagon-fill',
|
||||
type: 'fill',
|
||||
source: 'octagon',
|
||||
paint: {
|
||||
'fill-color': '#ff0000',
|
||||
'fill-opacity': 0.2
|
||||
|
||||
// Add polyline for multi-coordinate sites
|
||||
if (site.coordinates.length > 1) {
|
||||
map.addSource(`site-polyline-${site.name}`, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: site.coordinates
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: `site-polyline-${site.name}`,
|
||||
type: 'line',
|
||||
source: `site-polyline-${site.name}`,
|
||||
paint: {
|
||||
'line-color': '#4A9EFF',
|
||||
'line-width': 3,
|
||||
'line-dasharray': [2, 2]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add click handler for marker popups
|
||||
map.on('click', `site-marker-${site.name}`, (e) => {
|
||||
e.preventDefault();
|
||||
popup.value = {
|
||||
visible: true,
|
||||
x: e.point.x,
|
||||
y: e.point.y,
|
||||
type: 'site',
|
||||
feature: {
|
||||
properties: {
|
||||
name: site.name,
|
||||
description: site.description,
|
||||
type: site.type
|
||||
}
|
||||
},
|
||||
lngLat: site.coordinates[0]
|
||||
};
|
||||
});
|
||||
|
||||
// Cursor pointer on hover
|
||||
map.on('mouseenter', `site-marker-${site.name}`, () => {
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
});
|
||||
|
||||
map.on('mouseleave', `site-marker-${site.name}`, () => {
|
||||
map.getCanvas().style.cursor = '';
|
||||
});
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: 'octagon-outline',
|
||||
type: 'line',
|
||||
source: 'octagon',
|
||||
paint: {
|
||||
'line-color': '#ff0000',
|
||||
'line-width': 2
|
||||
|
||||
// Update popup position when map moves or zooms
|
||||
const updatePopupPosition = () => {
|
||||
if (popup.value.visible && popup.value.lngLat) {
|
||||
const point = map.project(popup.value.lngLat);
|
||||
popup.value.x = point.x;
|
||||
popup.value.y = point.y;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
map.on('move', updatePopupPosition);
|
||||
map.on('zoom', updatePopupPosition);
|
||||
|
||||
// Add geometry source and layers
|
||||
map.addSource('geometry', {
|
||||
@@ -1023,6 +1180,69 @@ onUnmounted(() => {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.control-section .section-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.subsection {
|
||||
margin-left: 10px;
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.hide-all-btn {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
margin: 8px 0;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.hide-all-btn:hover {
|
||||
background: #e8e8e8;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.site-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.site-control label {
|
||||
flex: 1;
|
||||
margin: 0 !important;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
.jump-btn {
|
||||
padding: 4px 8px;
|
||||
background: #4A9EFF;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.jump-btn:hover {
|
||||
background: #2E8FE3;
|
||||
}
|
||||
|
||||
.jump-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.control-section input[type="radio"],
|
||||
.control-section input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
@@ -1153,6 +1373,7 @@ onUnmounted(() => {
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
|
||||
z-index: 1000;
|
||||
min-width: 180px;
|
||||
max-width: 350px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1173,6 +1394,24 @@ onUnmounted(() => {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.site-popup {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.site-type {
|
||||
font-size: 11px !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #999 !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.site-description {
|
||||
line-height: 1.5;
|
||||
color: #333 !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.popup-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user