diff --git a/Dockerfile b/Dockerfile index d8a0057..065d1de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy only necessary Python scripts and web files COPY *.py ./ COPY web/ ./web/ +COPY map-checker/ ./map-checker/ # Create /data directory (will be mounted as volume) RUN mkdir -p /data diff --git a/README.md b/README.md index 5ef2610..f300c43 100644 --- a/README.md +++ b/README.md @@ -219,3 +219,5 @@ addr:state=FL addr:street=Hideaway Circle - Sr is SR (State Route) - Pky is Parkway +- The county sometimes lists garages or other units in the street name like "Vista Hills Street Garage 2" or "... Gs2" / "... Gs 2" which needs to be properly split out into street and unit +- Roads/lines need to be more clickable on the web UI, it's hard to click a ~3px element. \ No newline at end of file diff --git a/compare-addresses.py b/compare-addresses.py index 88ef42e..2dfe4af 100644 --- a/compare-addresses.py +++ b/compare-addresses.py @@ -155,6 +155,7 @@ class AddressComparator: if best_match is not None and distance_m <= self.tolerance_meters: existing_addresses.append({ 'geometry': local_point, + 'osm_geometry': osm_gdf.iloc[best_match].geometry, 'local_data': dict(local_row.drop('geometry')), 'osm_data': dict(osm_gdf.iloc[best_match].drop('geometry')), 'distance_meters': distance_m, @@ -218,8 +219,22 @@ class AddressComparator: print(f"Saved {len(removed_addresses)} potentially removed to {output_dir}/addresses-potentially-removed.geojson") if existing_addresses: + # Keeps both county city fields (postal community and legal + # city/jurisdiction - they can differ) plus OSM's addr:city, and + # the OSM point's coordinates, so the "Generate City Boundaries + # Report" step can build its report from this file alone without + # repeating the spatial match. existing_simple = [ - {'geometry': a['geometry'], 'distance_meters': a['distance_meters'], 'status': 'existing'} + { + 'geometry': a.get('osm_geometry') or a['geometry'], + 'distance_meters': a['distance_meters'], + 'addr:housenumber': a['local_data'].get('addr:housenumber'), + 'addr:street': a['local_data'].get('addr:street'), + 'addr:unit': a['local_data'].get('addr:unit'), + 'county_postal_community': a['local_data'].get('county_postal_community'), + 'county_city': a['local_data'].get('county_city'), + 'osm_city': a['osm_data'].get('addr:city'), + } for a in existing_addresses ] gpd.GeoDataFrame(existing_simple).to_file( diff --git a/convert-addresses.py b/convert-addresses.py index 11a6d2a..cacb5c6 100644 --- a/convert-addresses.py +++ b/convert-addresses.py @@ -133,6 +133,35 @@ def process_address_fields(gdf, exceptions): ] break + # Postal community (USPS mailing city) and city/jurisdiction (legal + # municipal boundary) are tracked separately from addr:city above - + # they're often the same value, but not always (e.g. a parcel legally + # inside Leesburg or Sumterville whose mail is addressed to "The + # Villages"). Kept for the City Boundaries report; addr:city above is + # still the single value actually used for the OSM import itself. + for field in ['POST_COMM', 'PostalCity']: + if field in processed.columns: + mapping['county_postal_community'] = [ + qgis_functions.title(str(v)) if pd.notna(v) else None + for v in processed[field] + ] + break + + def _clean_jurisdiction(v): + if pd.isna(v): + return None + s = str(v).strip() + if s.upper().startswith('UNINCORPORATED '): + s = s[len('UNINCORPORATED '):] + elif s.upper() == 'UNINCORPORATED': + return None + return qgis_functions.title(s) if s else None + + for field in ['CITY', 'Jurisdicti']: + if field in processed.columns: + mapping['county_city'] = [_clean_jurisdiction(v) for v in processed[field]] + break + # Apply street name exceptions (some are scoped to a specific city) if 'addr:street' in mapping and exceptions: mapping['addr:street'] = apply_street_exceptions( @@ -201,7 +230,10 @@ def convert(zip_path, output_path): gdf = gdf[gdf['addr:housenumber'].notna()].copy() osm_fields = ['addr:housenumber', 'addr:unit', 'addr:street', - 'addr:city', 'addr:postcode', 'addr:state'] + 'addr:city', 'addr:postcode', 'addr:state', + # Not real OSM tags - carried through for the City + # Boundaries report only, not part of the OSM import. + 'county_postal_community', 'county_city'] keep = [f for f in osm_fields if f in gdf.columns] gdf = gdf[keep + ['geometry']] diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 38cc387..73a288f 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -13,3 +13,5 @@ services: - ./shp-to-geojson.py:/app/shp-to-geojson.py - ./convert-addresses.py:/app/convert-addresses.py - ./compare-addresses.py:/app/compare-addresses.py + - ./generate-city-report.py:/app/generate-city-report.py + - ./map-checker:/app/map-checker diff --git a/download-overpass.py b/download-overpass.py index 551b7e2..83e2848 100644 --- a/download-overpass.py +++ b/download-overpass.py @@ -82,37 +82,53 @@ area(id:{area_id})->.searchArea;""" return query -def query_overpass(query): - """Send query to Overpass API and return JSON response, with retries.""" - url = "https://overpass-api.de/api/interpreter" - data = urllib.parse.urlencode({"data": query}).encode("utf-8") - req = urllib.request.Request( - url, - data=data, - headers={"Accept": "*/*", "User-Agent": "TheVillagesImport/1.0"}, - ) +# Public Overpass API mirrors, tried in order when the primary is overloaded. +OVERPASS_ENDPOINTS = [ + "https://overpass-api.de/api/interpreter", + "https://overpass.kumi.systems/api/interpreter", + "https://overpass.private.coffee/api/interpreter", +] - max_attempts = 3 - for attempt in range(1, max_attempts + 1): - try: - with urllib.request.urlopen(req, timeout=300) as response: - return json.loads(response.read().decode("utf-8")) - except urllib.error.HTTPError as e: + +def query_overpass(query, endpoints=None): + """Send query to Overpass API and return JSON response. + + Tries each endpoint in turn (with a couple of retries per endpoint on + 429/504) before falling through to the next mirror. Overpass instances + share the underlying database, so any mirror returns equivalent data. + """ + endpoints = endpoints or OVERPASS_ENDPOINTS + data = urllib.parse.urlencode({"data": query}).encode("utf-8") + + attempts_per_endpoint = 2 + for endpoint in endpoints: + req = urllib.request.Request( + endpoint, + data=data, + headers={"Accept": "*/*", "User-Agent": "TheVillagesImport/1.0"}, + ) + for attempt in range(1, attempts_per_endpoint + 1): try: - error_body = e.read().decode("utf-8") - except Exception: - error_body = "(unreadable)" - print(f"HTTP Error {e.code} on attempt {attempt}: {e.reason}", file=sys.stderr) - print(f"Error response body: {error_body}", file=sys.stderr) - if attempt < max_attempts and e.code in (429, 504): - wait = 30 * attempt - print(f"Retrying in {wait}s...", file=sys.stderr) - time.sleep(wait) - else: - sys.exit(1) - except Exception as e: - print(f"Error querying Overpass API: {e}", file=sys.stderr) - sys.exit(1) + print(f"Querying {endpoint} (attempt {attempt})...", file=sys.stderr) + with urllib.request.urlopen(req, timeout=300) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as e: + try: + error_body = e.read().decode("utf-8") + except Exception: + error_body = "(unreadable)" + print(f"HTTP Error {e.code} on {endpoint} attempt {attempt}: {e.reason}", file=sys.stderr) + print(f"Error response body: {error_body}", file=sys.stderr) + if attempt < attempts_per_endpoint and e.code in (429, 504): + wait = 30 * attempt + print(f"Retrying in {wait}s...", file=sys.stderr) + time.sleep(wait) + except Exception as e: + print(f"Error querying {endpoint}: {e}", file=sys.stderr) + break # non-HTTP error (timeout, connection refused): skip to next mirror + + print("All Overpass endpoints failed.", file=sys.stderr) + sys.exit(1) def convert_to_geojson(overpass_data): @@ -200,7 +216,13 @@ def main(): default="highways", help="Type of data to download (default: highways)" ) - + parser.add_argument( + "--endpoint", "-e", + action="append", + help="Overpass API endpoint to use (repeatable; tried in order). " + f"Default: {', '.join(OVERPASS_ENDPOINTS)}" + ) + args = parser.parse_args() # Create output directory if it doesn't exist @@ -212,7 +234,7 @@ def main(): # Build and execute query query = build_overpass_query(args.county, args.state, args.type) print(f"Query: {query}") - overpass_data = query_overpass(query) + overpass_data = query_overpass(query, endpoints=args.endpoint) # Convert to GeoJSON geojson = convert_to_geojson(overpass_data) diff --git a/generate-city-report.py b/generate-city-report.py new file mode 100644 index 0000000..7b05619 --- /dev/null +++ b/generate-city-report.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +Generate the City Boundaries report: addr:city agreement between the county +source and OpenStreetMap for already-matched addresses. + +Reads addresses-existing.geojson (produced by compare-addresses.py, which +already carries county_postal_community/county_city/osm_city for each +matched pair) and classifies each point against OSM twice - once per county +field, since the postal community (USPS mailing city) and the legal +city/jurisdiction can disagree with each other, not just with OSM. No +spatial matching happens here, so this is fast/cheap to re-run on its own +without repeating the "Diff vs OSM" step. + +Usage: + python generate-city-report.py \ + --existing-file /data/lake/addresses-existing.geojson \ + --output-dir /data/lake +""" + +import argparse +import sys +import warnings +from pathlib import Path + +import geopandas as gpd +import pandas as pd + +warnings.filterwarnings('ignore') + + +def normalize_city(value) -> str: + if value is None or (isinstance(value, float) and pd.isna(value)): + return '' + s = str(value).strip() + return '' if s in ('', 'nan', 'None') else s + + +def agreement_status(county_city, osm_city) -> str: + c = normalize_city(county_city) + o = normalize_city(osm_city) + if not c and not o: + return 'both_missing' + if not c: + return 'osm_only' + if not o: + return 'county_only' + return 'match' if c.lower() == o.lower() else 'mismatch' + + +def main(): + parser = argparse.ArgumentParser( + description="Generate the city-name agreement report from already-matched addresses", + epilog="Run compare-addresses.py first to produce addresses-existing.geojson." + ) + parser.add_argument('--existing-file', required=True, + help='addresses-existing.geojson from compare-addresses.py') + parser.add_argument('--output-dir', '-o', required=True, + help='Output directory for city-comparison.geojson') + args = parser.parse_args() + + existing_file = Path(args.existing_file) + output_dir = Path(args.output_dir) + + if not existing_file.exists(): + print(f"Error: {existing_file} not found.", file=sys.stderr) + print("Run 'Diff vs OSM' for addresses first.", file=sys.stderr) + return 1 + + gdf = gpd.read_file(existing_file) + + required = ['county_postal_community', 'county_city', 'osm_city'] + missing = [c for c in required if c not in gdf.columns] + if missing: + print(f"Error: {existing_file} is missing column(s): {', '.join(missing)}", file=sys.stderr) + print("This file predates the postal-community/city split - rerun " + "'Convert to GeoJSON' and 'Diff vs OSM' for this county " + "(convert-addresses.py needs to regenerate county-addresses.geojson " + "with the current field set) before generating the report.", file=sys.stderr) + return 1 + + gdf['status_postal_community'] = [ + agreement_status(c, o) + for c, o in zip(gdf['county_postal_community'], gdf['osm_city']) + ] + gdf['status_city'] = [ + agreement_status(c, o) + for c, o in zip(gdf['county_city'], gdf['osm_city']) + ] + + output_dir.mkdir(parents=True, exist_ok=True) + out_path = output_dir / "city-comparison.geojson" + gdf.to_file(out_path, driver='GeoJSON') + + print(f"Saved {len(gdf)} city comparisons to {out_path}") + for label, col in [('postal community', 'status_postal_community'), ('city', 'status_city')]: + print(f" vs {label}:") + for status, n in gdf[col].value_counts().items(): + print(f" {status}: {n}") + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/map-checker/README.md b/map-checker/README.md new file mode 100644 index 0000000..4631714 --- /dev/null +++ b/map-checker/README.md @@ -0,0 +1,70 @@ +# map-checker + +Cross-references our Sumter/Lake county address data against a reference map +(e.g. [thevillages.com/map](https://www.thevillages.com/map/)) to catch city +boundary or misnaming issues (e.g. addresses that are really in The Villages +but our data has as Lady Lake, Sumterville, Leesburg, etc.). + +## Why this is manual, not automated + +thevillages.com's Terms of Use explicitly prohibit automated access: + +> "You must not use any robot, spider or other automatic device, process or +> means to access the Website for any purpose, including monitoring or +> copying any of the material on the Website." + +So this subproject never fetches, scrapes, or otherwise programmatically +touches their site. Every input here comes from a human looking at the +official map in their own browser and either tracing a boundary or recording +an observation by hand. + +## Workflow 1: hand-traced boundary (recommended) + +Recording individual addresses one at a time doesn't scale to tens of +thousands of records. Instead, trace the boundary **once** and check every +address against it in one pass. + +1. Open `https://www.thevillages.com/map/` (or another reference map) in one + browser tab. +2. Open **`/map-checker`** in the app (the "Boundary Tracer" link on the main + page) in another tab. +3. Click "New polygon", then click the map to place vertices tracing the + boundary you see on the reference map. The Villages is disjoint across + three counties, so you'll likely want several separate polygons - just + name them all the same (e.g. "The Villages") and add another with "New + polygon" once you finish the current one. +4. Click "Finish polygon", give it a name, repeat for each disjoint piece. +5. Click "Save to server" (requires login) - this writes + `/data/map-checker/boundary.geojson`. "Download GeoJSON" also works if you + just want a local copy. +6. Run the point-in-polygon check: + + ```bash + docker compose exec web python3 map-checker/check-boundary.py --county lake --county sumter + ``` + + This flags: + - addresses **inside** the traced boundary whose county/OSM city fields + don't say "The Villages" (probably under-tagged), and + - addresses **outside** it that do say "The Villages" anyway (probably + over-tagged or the boundary needs another pass). + + Pass `--expect-city "Some Other City"` to check a different boundary/name, + and `--output flagged.geojson` to save the flagged points for review in + the main map viewer. + +## Workflow 2: individual spot-checks + +For one-off verification of a specific address (not a bulk boundary check), +[`reference.yml`](reference.yml) + `check.py` still works the same way as +before: hand-record what the reference map says for a specific street/area, +then run `python check.py` to compare it against our data. See the comments +at the top of `reference.yml` for the entry format. + +## Scope + +`check-boundary.py` is only as accurate as the hand-traced polygon - it's a +spot-check/audit tool, not a source of truth. For a systematic (but coarser) +look at where the county's own postal-community and city/jurisdiction fields +disagree with each other and with OSM, see the "City Boundaries" report in +the main app (`/reports/city-boundaries`). diff --git a/map-checker/check-boundary.py b/map-checker/check-boundary.py new file mode 100644 index 0000000..da04671 --- /dev/null +++ b/map-checker/check-boundary.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +Point-in-polygon check: for a hand-traced boundary (see the Boundary Tracer +tool at /map-checker, or draw manually and save as boundary.geojson), +flag every county/OSM address that disagrees with it - either inside the +boundary but not labeled with the expected city, or outside it but labeled +with the expected city anyway. + +The boundary itself is drawn by a human looking at a reference map in their +own browser (e.g. thevillages.com/map) - nothing here fetches from any +third-party site, only our own /data/{county}/*.geojson files. + +Usage: + python check-boundary.py --county lake --county sumter + python check-boundary.py --county lake --expect-city "The Villages" --output flagged.geojson +""" + +import argparse +import json +import sys +from pathlib import Path + +from shapely.geometry import shape, Point +from shapely.ops import unary_union +import warnings + +warnings.filterwarnings('ignore') + + +def normalize_city(value): + if value is None: + return '' + s = str(value).strip() + return '' if s in ('', 'nan', 'None') else s + + +def load_boundary(path): + with open(path) as f: + data = json.load(f) + polys = [] + for feat in data.get('features', []): + name = (feat.get('properties') or {}).get('name', 'boundary') + polys.append((name, shape(feat['geometry']))) + return polys + + +def load_addresses(data_dir, county): + county_dir = Path(data_dir) / county + # Prefer city-comparison.geojson: has postal community, city, AND osm_city + # together for matched addresses. Falls back to county-addresses.geojson + # (county-only, addr:city, no OSM side) if the diff step hasn't run yet. + for filename in ('city-comparison.geojson', 'county-addresses.geojson'): + path = county_dir / filename + if path.exists(): + with open(path) as f: + return json.load(f), filename + return None, None + + +def city_values(props): + values = [ + normalize_city(props.get('county_postal_community')), + normalize_city(props.get('county_city')), + normalize_city(props.get('osm_city')), + normalize_city(props.get('addr:city')), + ] + return [v for v in values if v] + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--boundary', default='/data/map-checker/boundary.geojson', + help='Path to the traced boundary GeoJSON (default: the file saved by ' + 'the Boundary Tracer web tool at /map-checker)') + parser.add_argument('--data-dir', default='/data') + parser.add_argument('--county', action='append', + help='County id to check (repeatable). Default: lake, sumter') + parser.add_argument('--expect-city', default='The Villages', + help='City name addresses inside the boundary should have (default: "The Villages")') + parser.add_argument('--output', help='Optional path to save flagged addresses as GeoJSON') + args = parser.parse_args() + + boundary_path = Path(args.boundary) + if not boundary_path.exists(): + print(f"Error: {boundary_path} not found.", file=sys.stderr) + print("Trace a boundary first via the Boundary Tracer tool (/map-checker), " + "or save one to that path by hand.", file=sys.stderr) + return 1 + + polys = load_boundary(boundary_path) + if not polys: + print("Error: boundary file has no polygons.", file=sys.stderr) + return 1 + combined = unary_union([g for _, g in polys]) + + counties = args.county or ['lake', 'sumter'] + expect = args.expect_city.strip().lower() + + total = inside_count = inside_flagged = outside_flagged = 0 + flagged_features = [] + + print(f"Boundary: {', '.join(sorted(set(name for name, _ in polys)))} " + f"({len(polys)} polygon(s))") + + for county in counties: + fc, source_file = load_addresses(args.data_dir, county) + if fc is None: + print(f" {county}: no data found, skipping.") + continue + + county_total = county_inside = county_inside_flagged = county_outside_flagged = 0 + + for feat in fc['features']: + coords = feat['geometry']['coordinates'] + pt = Point(coords[0], coords[1]) + is_inside = combined.contains(pt) + props = feat.get('properties', {}) + values = city_values(props) + says_expected = any(v.lower() == expect for v in values) + + county_total += 1 + if is_inside: + county_inside += 1 + if values and not says_expected: + county_inside_flagged += 1 + flagged_features.append({ + **feat, + 'properties': {**props, 'county': county, 'flag': 'inside_but_not_labeled'}, + }) + elif says_expected: + county_outside_flagged += 1 + flagged_features.append({ + **feat, + 'properties': {**props, 'county': county, 'flag': 'outside_but_labeled'}, + }) + + print(f" {county} ({source_file}): {county_total} addresses, " + f"{county_inside} inside boundary " + f"({county_inside_flagged} not labeled '{args.expect_city}'), " + f"{county_outside_flagged} outside boundary but labeled '{args.expect_city}'") + + total += county_total + inside_count += county_inside + inside_flagged += county_inside_flagged + outside_flagged += county_outside_flagged + + print(f"\n{'=' * 60}") + print(f"Total: {total} addresses checked, {inside_count} inside the boundary.") + print(f" Inside but not labeled '{args.expect_city}': {inside_flagged}") + print(f" Outside but labeled '{args.expect_city}': {outside_flagged}") + + if args.output: + out = {'type': 'FeatureCollection', 'features': flagged_features} + with open(args.output, 'w') as f: + json.dump(out, f) + print(f"\nSaved {len(flagged_features)} flagged addresses to {args.output}") + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/map-checker/check.py b/map-checker/check.py new file mode 100644 index 0000000..bb7f847 --- /dev/null +++ b/map-checker/check.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +map-checker: compare hand-recorded observations from thevillages.com/map +(see reference.yml) against our county/OSM address data. + +This is a deliberately manual workflow. thevillages.com's Terms of Use +prohibit automated/bot access to their site, so nothing here fetches from +them - reference.yml is filled in BY HAND after a human looks at the +official map in their own browser. This script only reads OUR OWN data +(county-addresses.geojson / city-comparison.geojson, already produced by +convert-addresses.py and generate-city-report.py) and checks it against +those manually recorded entries. + +Usage: + python check.py [--reference reference.yml] [--data-dir /data] [--radius 200] +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +import yaml + +DEFAULT_RADIUS_METERS = 200.0 + + +def normalize_city(value): + if not value: + return '' + s = str(value).strip() + return '' if s in ('', 'nan', 'None') else s + + +def normalize_street(value): + if not value: + return '' + s = str(value).strip().lower() + s = re.sub(r'\bstate road\b', 'sr', s) + s = re.sub(r'\bstate route\b', 'sr', s) + s = re.sub(r'\bcounty road\b', 'cr', s) + s = re.sub(r'\s+', ' ', s).strip() + return s + + +def haversine_meters(lat1, lon1, lat2, lon2): + r = 6371000.0 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * r * math.asin(math.sqrt(a)) + + +def load_geojson(path): + if not path.exists(): + return None + with open(path) as f: + return json.load(f) + + +def find_matches(entry, county_features, comparison_features, radius_m): + """Return a list of matched feature-property dicts for one reference entry. + + Prefers city-comparison.geojson (has county_postal_community, county_city, + AND osm_city together) when available; falls back to the raw + county-addresses.geojson (county-only, no OSM side) otherwise. + """ + source = comparison_features if comparison_features is not None else county_features + if source is None: + return [] + + street = entry.get('street') + housenumber = entry.get('housenumber') + lat, lon = entry.get('lat'), entry.get('lon') + + matches = [] + for feature in source['features']: + props = feature.get('properties', {}) + + if lat is not None and lon is not None: + coords = feature['geometry']['coordinates'] + dist = haversine_meters(lat, lon, coords[1], coords[0]) + if dist > radius_m: + continue + elif street: + if normalize_street(props.get('addr:street')) != normalize_street(street): + continue + if housenumber is not None: + try: + if int(props.get('addr:housenumber')) != int(housenumber): + continue + except (TypeError, ValueError): + continue + else: + continue + + matches.append(props) + + return matches + + +def check_entry(entry, index, county_features, comparison_features, radius_m): + label = entry.get('street') or f"({entry.get('lat')}, {entry.get('lon')})" + official_city = normalize_city(entry.get('official_city')) + matches = find_matches(entry, county_features, comparison_features, radius_m) + + print(f"\n[{index}] {label}" + + (f" #{entry['housenumber']}" if entry.get('housenumber') else '') + + f" ({entry.get('county', '?')})") + if entry.get('official_village'): + print(f" Official village: {entry['official_village']}") + print(f" Official city (recorded): {official_city or '(none recorded)'}") + + if not matches: + print(" No matching addresses found in our data - can't check.") + return {'checked': True, 'matched': False, 'mismatches': 0} + + # A single street address (e.g. a shopping plaza or apartment building) + # can match many unit rows that all carry identical city values - group + # those together instead of printing one line per unit. + groups = {} # (postal_community, city, osm_city) -> {'count', 'addr'} + for props in matches: + addr = ' '.join(str(v) for v in + [props.get('addr:housenumber'), props.get('addr:street')] if v) + key = (normalize_city(props.get('county_postal_community')), + normalize_city(props.get('county_city')), + normalize_city(props.get('osm_city'))) + if key not in groups: + groups[key] = {'count': 0, 'addr': addr} + groups[key]['count'] += 1 + + mismatches = 0 + for (postal, city, osm), g in groups.items(): + fields = [('postal community', postal), ('city', city), ('OSM city', osm)] + parts = [] + row_mismatch = False + for field_label, value in fields: + if not value: + continue + agrees = (not official_city) or value.lower() == official_city.lower() + if not agrees: + row_mismatch = True + flag = '' if agrees else ' <-- MISMATCH' + parts.append(f"{field_label}={value}{flag}") + count_note = f" (x{g['count']})" if g['count'] > 1 else '' + if row_mismatch: + mismatches += g['count'] + print(f" {g['addr']}{count_note}: " + ', '.join(parts)) + + return {'checked': True, 'matched': True, 'mismatches': mismatches} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--reference', default=str(Path(__file__).parent / 'reference.yml')) + parser.add_argument('--data-dir', default='/data') + parser.add_argument('--radius', type=float, default=DEFAULT_RADIUS_METERS, + help=f'Match radius in meters for lat/lon entries (default {DEFAULT_RADIUS_METERS})') + args = parser.parse_args() + + ref_path = Path(args.reference) + if not ref_path.exists(): + print(f"Error: {ref_path} not found.", file=sys.stderr) + return 1 + + with open(ref_path) as f: + data = yaml.safe_load(f) or {} + checks = data.get('checks') or [] + + if not checks: + print(f"No entries in {ref_path} yet.") + print("Open https://www.thevillages.com/map/ in your own browser, look up an address,") + print("and add an entry to reference.yml by hand - see the comments at the top of that file.") + return 0 + + data_dir = Path(args.data_dir) + cache = {} + total_mismatches = 0 + total_unmatched = 0 + + for i, entry in enumerate(checks, 1): + county = entry.get('county') + if not county: + print(f"\n[{i}] Skipped - missing 'county' field.") + continue + if county not in cache: + cache[county] = ( + load_geojson(data_dir / county / 'county-addresses.geojson'), + load_geojson(data_dir / county / 'city-comparison.geojson'), + ) + county_features, comparison_features = cache[county] + result = check_entry(entry, i, county_features, comparison_features, args.radius) + if not result['matched']: + total_unmatched += 1 + total_mismatches += result['mismatches'] + + print(f"\n{'=' * 60}") + print(f"Checked {len(checks)} entries: {total_mismatches} address(es) with a " + f"disagreeing city field, {total_unmatched} entries with no match in our data.") + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/map-checker/reference.yml b/map-checker/reference.yml new file mode 100644 index 0000000..79cdce2 --- /dev/null +++ b/map-checker/reference.yml @@ -0,0 +1,32 @@ +# map-checker reference log +# +# This file is filled in BY HAND. Open https://www.thevillages.com/map/ in +# your own browser, look up a street/area, and record what the official map +# says here. Nothing in this subproject fetches, scrapes, or otherwise +# automates access to thevillages.com — their Terms of Use explicitly +# prohibit automated/bot access ("You must not use any robot, spider or +# other automatic device, process or means to access the Website for any +# purpose, including monitoring or copying any of the material on the +# Website"), so every entry here is a manual, human-recorded observation. +# +# Fields: +# street - street name as it appears in county/OSM data (required +# unless lat/lon given instead) +# housenumber - optional, narrows the match to one address +# lat / lon - optional alternative to street: a point you looked up +# on the official map (e.g. right-clicked or geocoded) +# county - which county's data to check against: sumter | lake +# official_village - the named Village shown on thevillages.com/map (e.g. +# "Fenney", "DeLuna"), if shown - informational only +# official_city - what city/community the official map (or other +# authoritative source you consulted) indicates this +# address belongs to +# recorded_by - your name/username +# recorded_date - YYYY-MM-DD +# notes - anything else worth remembering (screenshots taken, +# ambiguity noticed, source other than the map, etc.) +# +# Run `python check.py` after adding entries to compare them against our +# county/OSM data and flag disagreements. + +checks: [] diff --git a/web/server.py b/web/server.py index 9b2ef5d..b111a8b 100644 --- a/web/server.py +++ b/web/server.py @@ -9,6 +9,7 @@ import subprocess import os import re import secrets +import shlex import shutil import threading import json @@ -185,6 +186,117 @@ def map_viewer(): """Map viewer page""" return render_template('map.html') +# ── Reports ──────────────────────────────────────────────────────────────── + +@app.route('/reports') +def reports_page(): + """Reports index page""" + return render_template('reports.html', current_user=session.get('username')) + +@app.route('/reports/city-boundaries') +def city_boundaries_report(): + """City name agreement report: county vs OSM addr:city, on a map""" + counties = get_counties() + available = [] + pinned_cities = {} + for county in counties: + path = f'/data/{county["id"]}/city-comparison.geojson' + if os.path.isfile(path): + available.append(county['id']) + pinned_cities[county['id']] = county.get('pinned_cities') or [] + return render_template('city-boundaries-report.html', + counties=counties, + available_counties=available, + pinned_cities=pinned_cities, + current_user=session.get('username')) + +# ── map-checker: hand-traced boundary tool ────────────────────────────────── +# +# thevillages.com's Terms of Use prohibit automated access, so this only ever +# saves what a human draws by hand while looking at the official map +# themselves - nothing here fetches from thevillages.com. See map-checker/README.md. + +MAP_CHECKER_DIR = '/data/map-checker' +MAP_CHECKER_BOUNDARY_FILE = os.path.join(MAP_CHECKER_DIR, 'boundary.geojson') +MAP_CHECKER_DRAFT_FILE = os.path.join(MAP_CHECKER_DIR, 'draft.json') + +@app.route('/map-checker') +@require_auth +def map_checker_page(): + """Boundary tracer: hand-trace a polygon against a reference map, save as GeoJSON. + + Requires login so tracing work is never done (and lost) as an anonymous session.""" + return render_template('boundary-tracer.html', + counties=get_counties(), + current_user=session.get('username')) + +@app.route('/api/map-checker/boundary', methods=['GET']) +@require_auth +def get_map_checker_boundary(): + if not os.path.isfile(MAP_CHECKER_BOUNDARY_FILE): + return jsonify({'type': 'FeatureCollection', 'features': []}) + with open(MAP_CHECKER_BOUNDARY_FILE) as f: + return jsonify(json.load(f)) + +@app.route('/api/map-checker/boundary', methods=['POST']) +@require_auth +def save_map_checker_boundary(): + data = request.json or {} + if data.get('type') != 'FeatureCollection' or not isinstance(data.get('features'), list): + return jsonify({'error': 'Expected a GeoJSON FeatureCollection'}), 400 + for feat in data['features']: + geom_type = ((feat.get('geometry') or {}).get('type')) + if geom_type not in ('Polygon', 'MultiPolygon'): + return jsonify({'error': f'Unsupported geometry type: {geom_type}'}), 400 + os.makedirs(MAP_CHECKER_DIR, exist_ok=True) + tmp = MAP_CHECKER_BOUNDARY_FILE + '.tmp' + with open(tmp, 'w') as f: + json.dump(data, f) + os.replace(tmp, MAP_CHECKER_BOUNDARY_FILE) + return jsonify({'success': True, 'features': len(data['features'])}) + + +def _is_latlng_list(val): + if not isinstance(val, list): + return False + for pt in val: + if not (isinstance(pt, list) and len(pt) == 2 and + all(isinstance(c, (int, float)) for c in pt)): + return False + return True + + +@app.route('/api/map-checker/draft', methods=['GET']) +@require_auth +def get_map_checker_draft(): + if not os.path.isfile(MAP_CHECKER_DRAFT_FILE): + return jsonify({'polygons': [], 'inProgress': []}) + with open(MAP_CHECKER_DRAFT_FILE) as f: + return jsonify(json.load(f)) + +@app.route('/api/map-checker/draft', methods=['POST']) +@require_auth +def save_map_checker_draft(): + """Autosave endpoint hit after every vertex/edit so in-progress tracing + survives a crashed tab or dropped connection, even before the user + explicitly finishes a polygon or clicks Save to server.""" + data = request.json or {} + polygons = data.get('polygons') + in_progress = data.get('inProgress', []) + if not isinstance(polygons, list): + return jsonify({'error': 'Expected a polygons list'}), 400 + for p in polygons: + if not (isinstance(p, dict) and isinstance(p.get('name'), str) and _is_latlng_list(p.get('latlngs'))): + return jsonify({'error': 'Malformed polygon entry'}), 400 + if not _is_latlng_list(in_progress): + return jsonify({'error': 'Malformed inProgress point list'}), 400 + os.makedirs(MAP_CHECKER_DIR, exist_ok=True) + tmp = MAP_CHECKER_DRAFT_FILE + '.tmp' + with open(tmp, 'w') as f: + json.dump({'polygons': polygons, 'inProgress': in_progress}, f) + os.replace(tmp, MAP_CHECKER_DRAFT_FILE) + return jsonify({'success': True}) + def get_script_map(): """Build script map dynamically from county configuration.""" counties = get_counties() @@ -197,6 +309,7 @@ def get_script_map(): osm_roads = {}; osm_addrs = {}; osm_paths = {} conv_roads = {}; conv_addrs = {}; conv_paths = {} diff_roads = {}; diff_addrs = {}; diff_paths = {} + city_report = {} for c in counties: cid = c['id'] @@ -226,6 +339,7 @@ def get_script_map(): diff_roads[cid] = ['python', 'diff-highways.py', f'{base}/osm-roads.geojson', f'{base}/county-roads.geojson', '--output', f'{base}/diff-roads.geojson'] diff_addrs[cid] = ['python', 'compare-addresses.py', '--local-file', f'{base}/county-addresses.geojson', '--osm-file', f'{base}/osm-addresses.geojson', '--output-dir', base] + city_report[cid] = ['python', 'generate-city-report.py', '--existing-file', f'{base}/addresses-existing.geojson', '--output-dir', base] if pu: diff_paths[cid] = ['python', 'diff-highways.py', f'{base}/osm-paths.geojson', f'{base}/county-paths.geojson', '--output', f'{base}/diff-paths.geojson'] @@ -241,6 +355,19 @@ def get_script_map(): sm['diff-roads'] = diff_roads sm['diff-addresses'] = diff_addrs if diff_paths: sm['diff-paths'] = diff_paths + sm['generate-city-report'] = city_report + + # Point-in-polygon check against the traced/imported boundary at + # /data/map-checker/boundary.geojson (see /map-checker and map-checker/README.md). + # Not per-county like the scripts above - one boundary can span multiple + # counties, so this checks all configured counties in a single run. + county_args = ' '.join(f'--county {shlex.quote(c["id"])}' for c in counties) + if county_args: + sm['boundary-check'] = ( + f'python map-checker/check-boundary.py {county_args} ' + f'--boundary /data/map-checker/boundary.geojson ' + f'--output /data/map-checker/flagged.geojson' + ) return sm @@ -548,6 +675,7 @@ DEFAULT_COUNTIES = [ 'roads_url': 'https://www.arcgis.com/sharing/rest/content/items/9177e17c72d3433aa79630c7eda84add/data', 'addresses_url': 'https://www.arcgis.com/sharing/rest/content/items/c75c5aac13a648968c5596b0665be28b/data', 'paths_url': 'upload', + 'pinned_cities': ['The Villages'], }, { 'id': 'lake', @@ -557,6 +685,7 @@ DEFAULT_COUNTIES = [ 'roads_url': 'https://c.lakecountyfl.gov/ftp/GIS/GisDownloads/Shapefiles/Streets.zip', 'addresses_url': 'https://c.lakecountyfl.gov/ftp/GIS/GisDownloads/Shapefiles/Addresspoints.zip', 'paths_url': '', + 'pinned_cities': ['The Villages'], }, ] diff --git a/web/static/boundary-tracer.js b/web/static/boundary-tracer.js new file mode 100644 index 0000000..644a257 --- /dev/null +++ b/web/static/boundary-tracer.js @@ -0,0 +1,478 @@ +// Boundary Tracer: hand-click a polygon boundary while looking at a +// reference map in another tab, save it as GeoJSON for map-checker's +// point-in-polygon analysis. Tracing itself never fetches from any +// third-party site; the optional overlay layer is the one exception and +// only ever fetches a URL the user explicitly typed in. + +const POLYGON_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948']; + +let map; +let polygons = []; // { id, name, latlngs: [[lat,lng],...], layer: L.Polygon } +let nextPolygonId = 1; + +let drawingActive = false; +let drawingPoints = []; // [[lat,lng],...] +let drawingLayer = null; // preview L.Polyline/L.Polygon +let vertexMarkers = []; + +// Overlay layer: the user pastes either a standard {z}/{x}/{y} tile URL, or +// the base URL of an ArcGIS tiled MapServer. Both are fetched directly by +// the browser from whatever URL the user gave - never proxied server-side. +// +// A generic {z}/{x}/{y} template is assumed to already follow the standard +// Web Mercator slippy-tile grid and is rendered with a plain L.tileLayer. +// +// An ArcGIS MapServer may instead publish tiles on its own scheme (see that +// service's own `?f=json` -> tileInfo: origin, per-level resolution, tile +// size). Many such caches - including local government ones - use a plain +// geographic (WGS84, wkid 4326) grid rather than Web Mercator, which a +// standard tile client can't address with z/x/y math. We handle that case by +// computing each source tile's true lat/lng box from tileInfo and dropping +// it in as an L.imageOverlay, which Leaflet projects correctly onto the +// Mercator base map regardless of the source's own projection. +let overlayMode = null; // 'xyz' | 'arcgis-geo' +let overlayTileLayer = null; // 'xyz' mode: L.tileLayer +let overlayGroup = null; // 'arcgis-geo' mode: L.layerGroup of L.imageOverlay +let overlayArcGisMeta = null; // 'arcgis-geo' mode: { baseUrl, tileInfo, opacity } +const OVERLAY_STORAGE_KEY = 'boundary-tracer-overlay'; +const MAX_ARCGIS_OVERLAY_TILES = 80; + +function initMap() { + map = initBaseMap('map', [28.7, -81.7], 10); + map.on('click', onMapClick); +} + +function isArcGisMapServerUrl(url) { + return /\/MapServer\/?$/i.test(url); +} + +function addOverlay(url, opacity) { + removeOverlay(); + url = url.trim(); + if (isArcGisMapServerUrl(url)) { + addArcGisGeoOverlay(url, opacity); + } else { + overlayMode = 'xyz'; + overlayTileLayer = L.tileLayer(url, { opacity, maxZoom: 22, maxNativeZoom: 20 }).addTo(map); + document.getElementById('removeOverlayBtn').disabled = false; + document.getElementById('overlayOpacityRow').style.display = 'flex'; + } + try { localStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify({ url, opacity })); } catch (e) {} +} + +function addArcGisGeoOverlay(baseUrl, opacity) { + const cleanUrl = baseUrl.replace(/\/$/, ''); + fetch(cleanUrl + '?f=json') + .then(r => r.json()) + .then(meta => { + const tileInfo = meta.tileInfo; + if (!tileInfo || !tileInfo.lods || !tileInfo.origin) { + showStatus('This ArcGIS service has no tileInfo - it may not be a cached/tiled MapServer.', 'error'); + return; + } + const wkid = (tileInfo.spatialReference || {}).wkid; + if (wkid !== 4326) { + showStatus(`Unsupported tile spatial reference (wkid ${wkid}). Only geographic (wkid 4326) ArcGIS tile schemes are supported.`, 'error'); + return; + } + overlayMode = 'arcgis-geo'; + overlayArcGisMeta = { baseUrl: cleanUrl, tileInfo, opacity }; + overlayGroup = L.layerGroup().addTo(map); + map.on('moveend', redrawArcGisOverlay); + document.getElementById('removeOverlayBtn').disabled = false; + document.getElementById('overlayOpacityRow').style.display = 'flex'; + redrawArcGisOverlay(); + showStatus('ArcGIS overlay loaded - it refreshes automatically as you pan/zoom.', 'success'); + }) + .catch(err => showStatus(`Failed to load ArcGIS service metadata: ${err.message}`, 'error')); +} + +function pickArcGisLevel(tileInfo) { + // Match the current Leaflet zoom to the tileInfo LOD with the closest + // resolution, using the standard Web Mercator meters-per-pixel formula + // converted to degrees. This is an approximation (good enough to pick + // the nearest of a handful of power-of-two levels) not an exact reprojection. + const zoom = map.getZoom(); + const metersPerPixel = 156543.03392 * Math.cos(map.getCenter().lat * Math.PI / 180) / Math.pow(2, zoom); + const degPerPixel = metersPerPixel / 111320; + let best = tileInfo.lods[0]; + let bestDiff = Infinity; + tileInfo.lods.forEach(lod => { + const diff = Math.abs(lod.resolution - degPerPixel); + if (diff < bestDiff) { bestDiff = diff; best = lod; } + }); + return best; +} + +function redrawArcGisOverlay() { + if (!overlayGroup || !overlayArcGisMeta) return; + overlayGroup.clearLayers(); + const { baseUrl, tileInfo, opacity } = overlayArcGisMeta; + const lod = pickArcGisLevel(tileInfo); + const tileSizeDeg = lod.resolution * tileInfo.cols; + const originX = tileInfo.origin.x, originY = tileInfo.origin.y; + const bounds = map.getBounds(); + + const colMin = Math.floor((bounds.getWest() - originX) / tileSizeDeg); + const colMax = Math.floor((bounds.getEast() - originX) / tileSizeDeg); + const rowMin = Math.floor((originY - bounds.getNorth()) / tileSizeDeg); + const rowMax = Math.floor((originY - bounds.getSouth()) / tileSizeDeg); + + const tileCount = (colMax - colMin + 1) * (rowMax - rowMin + 1); + if (tileCount > MAX_ARCGIS_OVERLAY_TILES) { + showStatus(`Overlay area too large at this zoom (${tileCount} tiles needed) - zoom in further.`, 'error'); + return; + } + + for (let row = rowMin; row <= rowMax; row++) { + for (let col = colMin; col <= colMax; col++) { + if (row < 0 || col < 0) continue; + const tileNorth = originY - row * tileSizeDeg; + const tileSouth = originY - (row + 1) * tileSizeDeg; + const tileWest = originX + col * tileSizeDeg; + const tileEast = originX + (col + 1) * tileSizeDeg; + const url = `${baseUrl}/tile/${lod.level}/${row}/${col}`; + L.imageOverlay(url, [[tileSouth, tileWest], [tileNorth, tileEast]], { opacity }).addTo(overlayGroup); + } + } +} + +function removeOverlay() { + if (overlayTileLayer) { map.removeLayer(overlayTileLayer); overlayTileLayer = null; } + if (overlayGroup) { map.removeLayer(overlayGroup); overlayGroup = null; } + map.off('moveend', redrawArcGisOverlay); + overlayArcGisMeta = null; + overlayMode = null; + document.getElementById('removeOverlayBtn').disabled = true; + document.getElementById('overlayOpacityRow').style.display = 'none'; + try { localStorage.removeItem(OVERLAY_STORAGE_KEY); } catch (e) {} +} + +function setOverlayOpacity(opacity) { + if (overlayMode === 'xyz' && overlayTileLayer) { + overlayTileLayer.setOpacity(opacity); + } else if (overlayMode === 'arcgis-geo' && overlayArcGisMeta) { + overlayArcGisMeta.opacity = opacity; + redrawArcGisOverlay(); + } + try { + const saved = JSON.parse(localStorage.getItem(OVERLAY_STORAGE_KEY) || 'null'); + if (saved) { saved.opacity = opacity; localStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(saved)); } + } catch (e) {} +} + +function restoreOverlayFromStorage() { + try { + const saved = JSON.parse(localStorage.getItem(OVERLAY_STORAGE_KEY) || 'null'); + if (saved && saved.url) { + document.getElementById('overlayUrlInput').value = saved.url; + document.getElementById('overlayOpacitySlider').value = Math.round((saved.opacity ?? 0.7) * 100); + addOverlay(saved.url, saved.opacity ?? 0.7); + } + } catch (e) {} +} + +function colorForIndex(i) { + return POLYGON_COLORS[i % POLYGON_COLORS.length]; +} + +function onMapClick(e) { + if (!drawingActive) return; + drawingPoints.push([e.latlng.lat, e.latlng.lng]); + redrawPreview(); + updateDrawButtons(); + saveDraft(); +} + +function redrawPreview() { + if (drawingLayer) { + map.removeLayer(drawingLayer); + drawingLayer = null; + } + vertexMarkers.forEach(m => map.removeLayer(m)); + vertexMarkers = []; + + if (drawingPoints.length === 0) return; + + if (drawingPoints.length >= 3) { + drawingLayer = L.polygon(drawingPoints, { + color: '#ffc107', weight: 2, fillOpacity: 0.15, dashArray: '5,5' + }).addTo(map); + } else { + drawingLayer = L.polyline(drawingPoints, { + color: '#ffc107', weight: 2, dashArray: '5,5' + }).addTo(map); + } + + drawingPoints.forEach(pt => { + const marker = L.circleMarker(pt, { + radius: 4, fillColor: '#ffc107', color: '#a67c00', weight: 1, fillOpacity: 1 + }).addTo(map); + vertexMarkers.push(marker); + }); +} + +function updateDrawButtons() { + document.getElementById('undoPointBtn').disabled = drawingPoints.length === 0; + document.getElementById('finishPolygonBtn').disabled = drawingPoints.length < 3; + const hint = document.getElementById('drawHint'); + if (drawingActive) { + hint.textContent = `${drawingPoints.length} point(s) placed. Click the map to add more, then "Finish polygon".`; + } +} + +function startDrawing() { + drawingActive = true; + drawingPoints = []; + redrawPreview(); + document.getElementById('newPolygonBtn').disabled = true; + document.getElementById('undoPointBtn').disabled = true; + document.getElementById('finishPolygonBtn').disabled = true; + document.getElementById('cancelPolygonBtn').disabled = false; + document.getElementById('drawHint').textContent = 'Click the map to place vertices.'; +} + +function undoPoint() { + if (drawingPoints.length === 0) return; + drawingPoints.pop(); + redrawPreview(); + updateDrawButtons(); + saveDraft(); +} + +function cancelDrawing() { + drawingActive = false; + drawingPoints = []; + if (drawingLayer) { map.removeLayer(drawingLayer); drawingLayer = null; } + vertexMarkers.forEach(m => map.removeLayer(m)); + vertexMarkers = []; + document.getElementById('newPolygonBtn').disabled = false; + document.getElementById('undoPointBtn').disabled = true; + document.getElementById('finishPolygonBtn').disabled = true; + document.getElementById('cancelPolygonBtn').disabled = true; + document.getElementById('drawHint').textContent = 'Click "New polygon" to start tracing.'; + saveDraft(); +} + +function finishDrawing() { + if (drawingPoints.length < 3) return; + const defaultName = polygons.length > 0 ? polygons[polygons.length - 1].name : 'The Villages'; + const name = window.prompt('Name for this polygon (e.g. "The Villages"):', defaultName); + if (name === null) return; // cancelled prompt - keep drawing state as-is + + const latlngs = drawingPoints.slice(); + if (drawingLayer) { map.removeLayer(drawingLayer); drawingLayer = null; } + vertexMarkers.forEach(m => map.removeLayer(m)); + vertexMarkers = []; + + addPolygon(name.trim() || 'Unnamed', latlngs); + + drawingActive = false; + drawingPoints = []; + document.getElementById('newPolygonBtn').disabled = false; + document.getElementById('undoPointBtn').disabled = true; + document.getElementById('finishPolygonBtn').disabled = true; + document.getElementById('cancelPolygonBtn').disabled = true; + document.getElementById('drawHint').textContent = 'Click "New polygon" to start tracing.'; + saveDraft(); +} + +function addPolygon(name, latlngs) { + const id = nextPolygonId++; + const color = colorForIndex(polygons.length); + const layer = L.polygon(latlngs, { color, weight: 2, fillOpacity: 0.2 }).addTo(map); + polygons.push({ id, name, latlngs, layer }); + renderPolygonList(); +} + +function deletePolygon(id) { + const idx = polygons.findIndex(p => p.id === id); + if (idx === -1) return; + map.removeLayer(polygons[idx].layer); + polygons.splice(idx, 1); + // Recolor remaining layers to keep the palette assignment stable-looking + polygons.forEach((p, i) => p.layer.setStyle({ color: colorForIndex(i) })); + renderPolygonList(); + saveDraft(); +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[c])); +} + +function renderPolygonList() { + const container = document.getElementById('polygonList'); + if (polygons.length === 0) { + container.innerHTML = '
None yet.
'; + return; + } + container.innerHTML = polygons.map((p, i) => ` ++ Open a reference map (e.g. thevillages.com/map) in another tab, then + hand-trace its boundary here by clicking vertices. Tracing itself + never fetches from any third-party site — you're just drawing on + our own map based on what you see elsewhere. The optional overlay + layer below is the exception: it fetches tiles live from whatever + URL you give it. +
+ +
+ Accepts a standard {z}/{x}/{y} tile URL, or the base
+ URL of an ArcGIS tiled MapServer (e.g. .../MapServer)
+ — its own tiling scheme (including a plain-geographic/WGS84 grid)
+ is read automatically from that service's metadata.
+
Click "New polygon" to start tracing.
+ +None yet.
+ County source data vs matched OSM addresses. +
+ +"Match" is unchecked by default (usually the majority of points) — check it to see agreeing addresses too.
+