Create new project design plans

This commit is contained in:
zyphlar
2026-08-28 14:23:26 -07:00
parent a7d6db8177
commit 7ce73f19c5
22 changed files with 2682 additions and 263 deletions
+1
View File
@@ -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
+2
View File
@@ -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.
+16 -1
View File
@@ -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(
+33 -1
View File
@@ -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']]
+2
View File
@@ -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
+52 -30
View File
@@ -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,6 +216,12 @@ 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()
@@ -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)
+104
View File
@@ -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())
+70
View File
@@ -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`).
+163
View File
@@ -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())
+209
View File
@@ -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())
+32
View File
@@ -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: []
+129
View File
@@ -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'],
},
]
+478
View File
@@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function renderPolygonList() {
const container = document.getElementById('polygonList');
if (polygons.length === 0) {
container.innerHTML = '<p style="font-size:11px;color:#999;">None yet.</p>';
return;
}
container.innerHTML = polygons.map((p, i) => `
<div class="polygon-item">
<span class="polygon-swatch" style="background:${colorForIndex(i)};"></span>
<span class="polygon-name">${escapeHtml(p.name)} (${p.latlngs.length} pts)</span>
<button onclick="window.__deletePolygon(${p.id})">Delete</button>
</div>
`).join('');
}
window.__deletePolygon = deletePolygon;
function polygonsToGeoJson() {
return {
type: 'FeatureCollection',
features: polygons.map(p => ({
type: 'Feature',
properties: { name: p.name },
geometry: {
type: 'Polygon',
// GeoJSON is [lng, lat] and rings must close (first == last)
coordinates: [[...p.latlngs.map(([lat, lng]) => [lng, lat]),
[p.latlngs[0][1], p.latlngs[0][0]]]]
}
}))
};
}
function explodeGeometry(geom) {
if (geom.type === 'Polygon') return [geom.coordinates];
if (geom.type === 'MultiPolygon') return geom.coordinates;
return [];
}
function loadExisting() {
fetch('/api/map-checker/boundary')
.then(r => r.json())
.then(geojson => {
(geojson.features || []).forEach(feat => {
const name = (feat.properties && feat.properties.name) || 'Unnamed';
explodeGeometry(feat.geometry).forEach(rings => {
const outerRing = rings[0]; // ignore holes
const latlngs = outerRing.slice(0, -1).map(([lng, lat]) => [lat, lng]); // drop closing point
if (latlngs.length >= 3) addPolygon(name, latlngs);
});
});
if (polygons.length > 0) {
const bounds = L.latLngBounds(polygons.flatMap(p => p.latlngs));
map.fitBounds(bounds, { padding: [50, 50] });
}
})
.catch(err => showStatus(`Failed to load existing boundary: ${err.message}`, 'error'));
}
// Autosave: fired after every vertex click, undo, delete, and finish so an
// in-progress trace survives a crashed tab or dropped connection without
// needing an explicit "Save to server" click.
function saveDraft() {
fetch('/api/map-checker/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body: JSON.stringify({
polygons: polygons.map(p => ({ name: p.name, latlngs: p.latlngs })),
inProgress: drawingPoints
})
}).catch(err => showStatus(`Autosave failed: ${err.message}`, 'error'));
}
function loadInitialState() {
fetch('/api/map-checker/draft')
.then(r => r.json())
.then(draft => {
const hasDraft = draft && (
(draft.polygons && draft.polygons.length > 0) ||
(draft.inProgress && draft.inProgress.length > 0)
);
if (!hasDraft) {
loadExisting();
return;
}
(draft.polygons || []).forEach(p => {
if (p.latlngs && p.latlngs.length >= 3) addPolygon(p.name || 'Unnamed', p.latlngs);
});
if (draft.inProgress && draft.inProgress.length > 0) {
drawingActive = true;
drawingPoints = draft.inProgress.slice();
redrawPreview();
document.getElementById('newPolygonBtn').disabled = true;
document.getElementById('cancelPolygonBtn').disabled = false;
updateDrawButtons();
}
const allPts = polygons.flatMap(p => p.latlngs).concat(drawingPoints);
if (allPts.length > 0) {
map.fitBounds(L.latLngBounds(allPts), { padding: [50, 50] });
}
showStatus('Restored autosaved work from your last session.', 'success');
})
.catch(err => showStatus(`Failed to load autosaved draft: ${err.message}`, 'error'));
}
function showStatus(msg, type) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = `status ${type}`;
}
function saveToServer() {
if (polygons.length === 0) {
showStatus('No polygons to save yet.', 'error');
return;
}
fetch('/api/map-checker/boundary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(polygonsToGeoJson())
})
.then(r => r.json())
.then(data => {
if (data.error) { showStatus(`Error: ${data.error}`, 'error'); return; }
showStatus(`Saved ${data.features} polygon(s) to /data/map-checker/boundary.geojson`, 'success');
})
.catch(err => showStatus(`Error: ${err.message}`, 'error'));
}
function downloadGeoJson() {
if (polygons.length === 0) {
showStatus('No polygons to download yet.', 'error');
return;
}
const blob = new Blob([JSON.stringify(polygonsToGeoJson(), null, 2)], { type: 'application/geo+json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'boundary.geojson';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
document.addEventListener('DOMContentLoaded', function() {
initMap();
initMobileControls();
loadInitialState();
restoreOverlayFromStorage();
document.getElementById('newPolygonBtn').addEventListener('click', startDrawing);
document.getElementById('undoPointBtn').addEventListener('click', undoPoint);
document.getElementById('finishPolygonBtn').addEventListener('click', finishDrawing);
document.getElementById('cancelPolygonBtn').addEventListener('click', cancelDrawing);
document.getElementById('saveBtn').addEventListener('click', saveToServer);
document.getElementById('downloadBtn').addEventListener('click', downloadGeoJson);
document.getElementById('addOverlayBtn').addEventListener('click', () => {
const url = document.getElementById('overlayUrlInput').value.trim();
if (!url) { showStatus('Enter a tile URL first.', 'error'); return; }
const hasXyzPlaceholders = /\{z\}/.test(url) && /\{x\}/.test(url) && /\{y\}/.test(url);
if (!hasXyzPlaceholders && !isArcGisMapServerUrl(url)) {
showStatus('URL should contain {z}, {x} and {y} placeholders, or be an ArcGIS .../MapServer base URL.', 'error');
return;
}
const opacity = document.getElementById('overlayOpacitySlider').value / 100;
addOverlay(url, opacity);
});
document.getElementById('removeOverlayBtn').addEventListener('click', removeOverlay);
document.getElementById('overlayOpacitySlider').addEventListener('input', (e) => {
setOverlayOpacity(e.target.value / 100);
});
});
+207
View File
@@ -0,0 +1,207 @@
/* Shared chrome for Leaflet map pages: full-screen map, the slide-out
controls panel, mobile hamburger toggle, and status banner. Page-specific
controls (layer lists, legends, buttons) are styled by each page. */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
}
#map {
flex: 1;
width: 100%;
}
.controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
padding: 10px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
min-width: 175px;
max-height: calc(100vh - 20px);
overflow-y: auto;
}
.controls h3 {
margin: 0 0 6px 0;
font-size: 12px;
color: #333;
}
.controls label {
display: flex;
align-items: center;
margin: 4px 0;
cursor: pointer;
font-size: 11px;
}
.controls input[type="checkbox"] {
margin-right: 8px;
}
.controls select {
width: 100%;
font-size: 11px;
padding: 3px;
border: 1px solid #ccc;
border-radius: 4px;
}
.controls button {
width: 100%;
padding: 7px;
margin-top: 7px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.controls button:hover {
background: #0056b3;
}
.controls button:disabled {
background: #ccc;
cursor: not-allowed;
}
.status {
margin-top: 7px;
padding: 6px;
border-radius: 4px;
font-size: 11px;
text-align: center;
}
.status.success {
background: #d4edda;
color: #155724;
}
.status.error {
background: #f8d7da;
color: #721c24;
}
.status.hidden {
display: none;
}
.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);
}
@media (max-width: 768px) {
.hamburger-button {
display: flex;
}
.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 {
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;
}
.btn-back {
display: block;
margin-bottom: 12px;
padding: 5px 10px;
background: #1a1a2e;
color: #ddd;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-decoration: none;
text-align: center;
}
.btn-back:hover { background: #2d2d4e; color: #fff; }
+41
View File
@@ -0,0 +1,41 @@
// Shared chrome for Leaflet-based pages: base tile layer and the mobile
// hamburger/controls-drawer behavior used by both the editing map viewer
// and read-only reports.
function initBaseMap(elementId, view, zoom) {
const map = L.map(elementId).setView(view || [28.7, -81.7], 12);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
if (zoom) map.setZoom(zoom);
return map;
}
// Wires up the hamburger button / slide-out controls panel / overlay used
// on mobile. Expects #hamburgerButton, #controls, #overlay in the page.
function initMobileControls() {
const hamburgerButton = document.getElementById('hamburgerButton');
const controls = document.getElementById('controls');
const overlay = document.getElementById('overlay');
if (!hamburgerButton || !controls || !overlay) return;
function toggleControls() {
controls.classList.toggle('open');
hamburgerButton.classList.toggle('active');
overlay.classList.toggle('active');
}
function closeControls() {
controls.classList.remove('open');
hamburgerButton.classList.remove('active');
overlay.classList.remove('active');
}
window.closeMobileControls = closeControls;
hamburgerButton.addEventListener('click', toggleControls);
overlay.addEventListener('click', closeControls);
}
+3 -32
View File
@@ -23,7 +23,6 @@ let selectionBox = null;
let multiSelectMode = false;
// Mobile controls state
let mobileControlsOpen = false;
let loadedCounty = '';
let loadedDataType = '';
@@ -43,13 +42,7 @@ let popupLatlng = null;
// Initialize map
function initMap() {
map = L.map('map').setView([28.7, -81.7], 12);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
map = initBaseMap('map', [28.7, -81.7], 12);
snapPreviewLayer = L.layerGroup().addTo(map);
@@ -2103,30 +2096,8 @@ function toggleLayer(layerId, layer) {
document.addEventListener('DOMContentLoaded', function() {
initMap();
// Mobile hamburger menu toggle
const hamburgerButton = document.getElementById('hamburgerButton');
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);
overlay.addEventListener('click', closeControls);
// Mobile hamburger menu toggle (shared helper in map-ui.js)
initMobileControls();
// Layer toggles
document.getElementById('osmToggle').addEventListener('change', function() {
+597
View File
@@ -0,0 +1,597 @@
// City Boundaries report. Two independent views over the same data:
// - Agreement Status: county vs OSM addr:city agreement, color-coded.
// - City Names: what one source (county or OSM) thinks each address's city
// is, color-coded by identity - a visual read of what that source's
// "boundaries" look like. No agreement analysis in this view.
const STATUS_CONFIG = {
match: { color: '#28a745', label: 'Match' },
mismatch: { color: '#dc3545', label: 'Mismatch' },
county_only: { color: '#fd7e14', label: 'Missing in OSM' },
osm_only: { color: '#17a2b8', label: 'Missing in county' },
both_missing: { color: '#6c757d', label: 'Both missing' },
};
// Categorical identity palette (dataviz skill reference instance, fixed hue
// order). Only city names among the 8 most common in this dataset get their
// own hue - the rest fold into "Other" per the skill's rule for identity
// channels with more categories than a palette can keep pairwise-distinct.
// Every marker is still identifiable exactly via its popup and the legend's
// text labels, so color is never the only channel carrying identity.
const CITY_PALETTE = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'];
const CITY_OTHER_COLOR = '#898781';
const CITY_OTHER_LABEL = 'Other';
const CITY_UNKNOWN_COLOR = '#c3c2b7';
const CITY_UNKNOWN_LABEL = '(no city)';
const CITY_UNKNOWN_KEY = '__unknown__';
const CITY_OTHER_KEY = '__other__';
// Boundary Check view: point-in-polygon results from map-checker/check-boundary.py
// against the traced/imported boundary at /data/map-checker/boundary.geojson.
// Independent of the county-vs-OSM tag comparison above - this is a separate
// data source (an explicit reference boundary) checked with actual geometry
// rather than by comparing text fields.
const FLAG_CONFIG = {
inside_but_not_labeled: { color: '#dc3545', label: "Inside boundary, not labeled expected city" },
outside_but_labeled: { color: '#fd7e14', label: "Outside boundary, labeled expected city" },
};
let boundaryOutlineLayer = null; // L.geoJSON of the traced boundary polygon(s)
let boundaryFlagLayers = {}; // flag -> L.layerGroup
let boundaryLoaded = false; // avoid re-fetching every time the tab is switched
let boundaryJobId = null;
let boundaryLogInterval = null;
let map;
let statusLayers = {}; // status -> L.layerGroup
let cityLayers = {}; // city name (or CITY_OTHER_KEY/CITY_UNKNOWN_KEY) -> L.layerGroup
let currentGeojson = null;
let currentView = 'agreement'; // 'agreement' | 'city-names' | 'boundary-check'
let compareField = 'postal_community'; // 'postal_community' | 'city' - which county attribute to compare against OSM
let citySource = 'county'; // 'county' | 'osm' - which side to color by, in the City Names view
let cityColorMap = {}; // canonical (lowercased) name -> hex, stable across county/osm toggle
let cityLabelMap = {}; // canonical name -> display label (most common raw casing)
let currentCountyId = null;
// The county-side property name for the currently selected Compare field.
function countyFieldName() {
return compareField === 'city' ? 'county_city' : 'county_postal_community';
}
// The status property name (precomputed server-side) for the current field.
function statusFieldName() {
return compareField === 'city' ? 'status_city' : 'status_postal_community';
}
// The property actually driving color in the City Names view right now.
function activeCityField() {
return citySource === 'osm' ? 'osm_city' : countyFieldName();
}
function initMap() {
map = initBaseMap('map', [28.7, -81.7], 11);
Object.keys(STATUS_CONFIG).forEach(status => {
statusLayers[status] = L.layerGroup();
});
Object.keys(FLAG_CONFIG).forEach(flag => {
boundaryFlagLayers[flag] = L.layerGroup();
});
}
function markerStyle(color) {
return {
radius: 6,
fillColor: color,
color: '#fff',
weight: 1,
opacity: 1,
fillOpacity: 0.85,
};
}
function escapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function popupHtml(feature) {
const p = feature.properties || {};
const activeStatus = p[statusFieldName()];
const cfg = STATUS_CONFIG[activeStatus] || {};
const compareLabel = compareField === 'city' ? 'city' : 'postal community';
const addrParts = [p['addr:housenumber'], p['addr:street']].filter(Boolean).join(' ');
const unit = p['addr:unit'] ? ` Unit ${escapeHtml(p['addr:unit'])}` : '';
let html = '<div style="font-size:12px;">';
html += `<div style="font-weight:600;margin-bottom:4px;">${escapeHtml(addrParts) || '(no address)'}${unit}</div>`;
html += `<div><strong>County postal community:</strong> ${escapeHtml(p.county_postal_community) || '<em>none</em>'}</div>`;
html += `<div><strong>County city:</strong> ${escapeHtml(p.county_city) || '<em>none</em>'}</div>`;
html += `<div><strong>OSM city:</strong> ${escapeHtml(p.osm_city) || '<em>none</em>'}</div>`;
html += `<div style="margin-top:6px;"><strong>Agreement (vs ${compareLabel}):</strong> ${escapeHtml(cfg.label || activeStatus)}</div>`;
if (p.distance_meters !== undefined && p.distance_meters !== null) {
html += `<div><strong>Match distance:</strong> ${Number(p.distance_meters).toFixed(1)} m</div>`;
}
html += '</div>';
return html;
}
// ---- Agreement Status view ----
function updateStatusCounts(counts) {
Object.keys(STATUS_CONFIG).forEach(status => {
const el = document.getElementById(`count-${status}`);
if (el) el.textContent = counts[status] || 0;
});
}
function renderStatusLayers(geojson) {
Object.values(statusLayers).forEach(layer => layer.clearLayers());
const field = statusFieldName();
const counts = {};
geojson.features.forEach(feature => {
const status = feature.properties && feature.properties[field];
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.both_missing;
const layer = statusLayers[status] || statusLayers.both_missing;
const coords = feature.geometry.coordinates;
const marker = L.circleMarker(L.latLng(coords[1], coords[0]), markerStyle(cfg.color));
marker.bindPopup(popupHtml(feature), { maxWidth: 280 });
layer.addLayer(marker);
counts[status] = (counts[status] || 0) + 1;
});
updateStatusCounts(counts);
}
function applyStatusLayerVisibility() {
document.querySelectorAll('#legend .legend-item').forEach(item => {
const status = item.dataset.status;
const checked = item.querySelector('input[type="checkbox"]').checked;
const layer = statusLayers[status];
if (!layer) return;
if (checked) layer.addTo(map);
else if (map.hasLayer(layer)) map.removeLayer(layer);
});
}
// ---- City Names view ----
function cityKey(name) {
const trimmed = name && String(name).trim();
return trimmed ? trimmed : null;
}
// Case-insensitive grouping key so source data noise like "Clermont" vs
// "CLermont" doesn't fragment into separate colors/legend rows.
function canonicalKey(rawName) {
return rawName ? rawName.toLowerCase() : null;
}
// Colors are assigned once per dataset load, ranked by combined frequency
// across BOTH the active county field (postal community or city, per the
// Compare dropdown) and osm_city. This keeps a given city's color fixed
// regardless of which side (county/OSM) is currently selected or which
// layers are toggled - identity never gets repainted out from under the
// user. Colors ARE re-ranked when the Compare field itself changes, since
// that's genuinely a different attribute.
//
// pinnedNames (raw strings, e.g. from counties.yml's pinned_cities) always
// claim a palette slot ahead of frequency ranking, as long as they actually
// appear in the data - so a place like "The Villages" that a county considers
// important never gets folded into "Other" just because it's a numeric
// minority in this particular dataset.
function buildCityColorMap(geojson, pinnedNames, countyField) {
const counts = new Map(); // canonical key -> total count
const labelCounts = new Map(); // canonical key -> Map(raw label -> count)
geojson.features.forEach(feature => {
const p = feature.properties || {};
[cityKey(p[countyField]), cityKey(p.osm_city)].forEach(raw => {
if (!raw) return;
const key = canonicalKey(raw);
counts.set(key, (counts.get(key) || 0) + 1);
if (!labelCounts.has(key)) labelCounts.set(key, new Map());
const lm = labelCounts.get(key);
lm.set(raw, (lm.get(raw) || 0) + 1);
});
});
const rankedByFrequency = [...counts.keys()].sort((a, b) => counts.get(b) - counts.get(a));
const pinnedKeys = (pinnedNames || [])
.map(canonicalKey)
.filter(key => counts.has(key)); // only pin names that actually occur here
const pinnedSet = new Set(pinnedKeys);
// Pinned names first (in the order given), then the rest by frequency.
const ranked = [...pinnedKeys, ...rankedByFrequency.filter(key => !pinnedSet.has(key))];
cityColorMap = {};
cityLabelMap = {};
ranked.forEach((key, i) => {
cityColorMap[key] = i < CITY_PALETTE.length ? CITY_PALETTE[i] : CITY_OTHER_COLOR;
// Display label: whichever raw casing was most common for this key.
let bestLabel = key, bestCount = -1;
labelCounts.get(key).forEach((c, label) => {
if (c > bestCount) { bestCount = c; bestLabel = label; }
});
cityLabelMap[key] = bestLabel;
});
}
function colorForCity(canonKey) {
if (canonKey === null) return CITY_UNKNOWN_COLOR;
return cityColorMap[canonKey] || CITY_OTHER_COLOR;
}
function renderCityLayers(geojson, field) {
Object.values(cityLayers).forEach(layer => { layer.clearLayers(); if (map.hasLayer(layer)) map.removeLayer(layer); });
cityLayers = {};
// Per-field counts for the legend (independent of the shared color ranking).
const counts = new Map();
geojson.features.forEach(feature => {
const raw = cityKey((feature.properties || {})[field]);
const canon = canonicalKey(raw);
const color = colorForCity(canon);
// Named (palette) cities each get their own bucket; everything folded
// to CITY_OTHER_COLOR shares a single "Other" bucket/legend row.
let bucketKey;
if (canon === null) bucketKey = CITY_UNKNOWN_KEY;
else if (color === CITY_OTHER_COLOR) bucketKey = CITY_OTHER_KEY;
else bucketKey = canon;
if (!cityLayers[bucketKey]) cityLayers[bucketKey] = L.layerGroup();
const coords = feature.geometry.coordinates;
const marker = L.circleMarker(L.latLng(coords[1], coords[0]), markerStyle(color));
marker.bindPopup(popupHtml(feature), { maxWidth: 280 });
cityLayers[bucketKey].addLayer(marker);
counts.set(bucketKey, (counts.get(bucketKey) || 0) + 1);
});
renderCityLegend(counts);
applyCityLayerVisibility();
}
function renderCityLegend(counts) {
const container = document.getElementById('cityLegend');
container.innerHTML = '';
// Named cities first, ranked by their assigned palette slot (fixed order),
// then Other, then Unknown - each only if present in this field's data.
const namedEntries = Object.keys(cityColorMap)
.filter(key => counts.has(key) && cityColorMap[key] !== CITY_OTHER_COLOR)
.sort((a, b) => CITY_PALETTE.indexOf(cityColorMap[a]) - CITY_PALETTE.indexOf(cityColorMap[b]));
const rows = namedEntries.map(key => ({ key, label: cityLabelMap[key], color: cityColorMap[key] }));
if (counts.has(CITY_OTHER_KEY)) rows.push({ key: CITY_OTHER_KEY, label: CITY_OTHER_LABEL, color: CITY_OTHER_COLOR });
if (counts.has(CITY_UNKNOWN_KEY)) rows.push({ key: CITY_UNKNOWN_KEY, label: CITY_UNKNOWN_LABEL, color: CITY_UNKNOWN_COLOR });
rows.forEach(({ key, label, color }) => {
const item = document.createElement('label');
item.className = 'legend-item';
item.dataset.cityKey = key;
item.innerHTML = `
<input type="checkbox" checked>
<span class="legend-swatch" style="background:${color};"></span>
<span class="legend-label">${escapeHtml(label)}</span>
<span class="legend-count">${counts.get(key) || 0}</span>
`;
item.querySelector('input').addEventListener('change', function() {
const layer = cityLayers[key];
if (!layer) return;
if (this.checked) layer.addTo(map);
else if (map.hasLayer(layer)) map.removeLayer(layer);
});
container.appendChild(item);
});
}
function applyCityLayerVisibility() {
document.querySelectorAll('#cityLegend .legend-item').forEach(item => {
const key = item.dataset.cityKey;
const checked = item.querySelector('input[type="checkbox"]').checked;
const layer = cityLayers[key];
if (!layer) return;
if (checked) layer.addTo(map);
else if (map.hasLayer(layer)) map.removeLayer(layer);
});
}
// ---- Boundary Check view ----
function boundaryPopupHtml(feature) {
const p = feature.properties || {};
const cfg = FLAG_CONFIG[p.flag] || {};
const addrParts = [p['addr:housenumber'], p['addr:street']].filter(Boolean).join(' ');
const unit = p['addr:unit'] ? ` Unit ${escapeHtml(p['addr:unit'])}` : '';
let html = '<div style="font-size:12px;">';
html += `<div style="font-weight:600;margin-bottom:4px;">${escapeHtml(addrParts) || '(no address)'}${unit}</div>`;
html += `<div><strong>County:</strong> ${escapeHtml(p.county) || ''}</div>`;
html += `<div><strong>County postal community:</strong> ${escapeHtml(p.county_postal_community) || '<em>none</em>'}</div>`;
html += `<div><strong>County city:</strong> ${escapeHtml(p.county_city) || '<em>none</em>'}</div>`;
html += `<div><strong>OSM city:</strong> ${escapeHtml(p.osm_city) || '<em>none</em>'}</div>`;
html += `<div style="margin-top:6px;"><strong>Flag:</strong> ${escapeHtml(cfg.label || p.flag)}</div>`;
html += '</div>';
return html;
}
function renderBoundaryOutline(geojson) {
if (boundaryOutlineLayer) { map.removeLayer(boundaryOutlineLayer); boundaryOutlineLayer = null; }
boundaryOutlineLayer = L.geoJSON(geojson, {
style: { color: '#6f42c1', weight: 2, fillOpacity: 0.05 }
}).bindPopup(layer => (layer.feature.properties || {}).name || 'Boundary');
}
function renderBoundaryFlags(geojson) {
Object.values(boundaryFlagLayers).forEach(layer => layer.clearLayers());
const counts = {};
(geojson.features || []).forEach(feature => {
const flag = feature.properties && feature.properties.flag;
const layer = boundaryFlagLayers[flag];
if (!layer) return; // unknown flag value - skip rather than guess a bucket
const cfg = FLAG_CONFIG[flag];
const coords = feature.geometry.coordinates;
const marker = L.circleMarker(L.latLng(coords[1], coords[0]), markerStyle(cfg.color));
marker.bindPopup(boundaryPopupHtml(feature), { maxWidth: 280 });
layer.addLayer(marker);
counts[flag] = (counts[flag] || 0) + 1;
});
Object.keys(FLAG_CONFIG).forEach(flag => {
const el = document.getElementById(`count-${flag}`);
if (el) el.textContent = counts[flag] || 0;
});
}
function applyBoundaryLayerVisibility() {
document.querySelectorAll('#boundaryLegend .legend-item').forEach(item => {
const flag = item.dataset.flag;
const checked = item.querySelector('input[type="checkbox"]').checked;
const layer = flag === '__outline__' ? boundaryOutlineLayer : boundaryFlagLayers[flag];
if (!layer) return;
if (checked) layer.addTo(map);
else if (map.hasLayer(layer)) map.removeLayer(layer);
});
}
function loadBoundarySection(force) {
if (boundaryLoaded && !force) {
applyBoundaryLayerVisibility();
return;
}
fetch('/data/map-checker/boundary.geojson')
.then(r => r.ok ? r.json() : null)
.then(geojson => {
if (boundaryOutlineLayer) { map.removeLayer(boundaryOutlineLayer); boundaryOutlineLayer = null; }
if (geojson && (geojson.features || []).length) renderBoundaryOutline(geojson);
if (currentView === 'boundary-check') applyBoundaryLayerVisibility();
})
.catch(() => {});
fetch('/data/map-checker/flagged.geojson')
.then(resp => {
if (!resp.ok) throw new Error('not generated yet');
const lastMod = resp.headers.get('Last-Modified');
document.getElementById('boundaryMeta').textContent = lastMod ? `Last run: ${new Date(lastMod).toLocaleString()}` : '';
return resp.json();
})
.then(geojson => {
document.getElementById('boundaryNoData').style.display = 'none';
renderBoundaryFlags(geojson);
boundaryLoaded = true;
if (currentView === 'boundary-check') applyBoundaryLayerVisibility();
})
.catch(() => {
document.getElementById('boundaryNoData').style.display = '';
document.getElementById('boundaryMeta').textContent = '';
Object.keys(FLAG_CONFIG).forEach(flag => {
const el = document.getElementById(`count-${flag}`);
if (el) el.textContent = '–';
});
if (currentView === 'boundary-check' && boundaryOutlineLayer) applyBoundaryLayerVisibility();
});
}
function requireLoginForBoundaryCheck() {
showStatus('Not authenticated – please log in.', 'error');
setTimeout(() => { window.location.href = '/login?next=' + encodeURIComponent(window.location.pathname); }, 1200);
}
function runBoundaryCheck() {
if (!IS_AUTHENTICATED) return requireLoginForBoundaryCheck();
const btn = document.getElementById('runBoundaryCheckBtn');
const logEl = document.getElementById('boundaryLog');
btn.disabled = true;
logEl.style.display = 'block';
logEl.textContent = '';
document.getElementById('boundaryMeta').textContent = 'Running…';
fetch('/api/run-script', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ script: 'boundary-check' })
})
.then(r => r.json())
.then(data => {
if (data.error) {
logEl.textContent = `Error: ${data.error}`;
btn.disabled = false;
return;
}
boundaryJobId = data.job_id;
if (boundaryLogInterval) clearInterval(boundaryLogInterval);
boundaryLogInterval = setInterval(checkBoundaryJobStatus, 1000);
})
.catch(err => {
logEl.textContent = `Error: ${err.message}`;
btn.disabled = false;
});
}
function checkBoundaryJobStatus() {
if (!boundaryJobId) return;
fetch(`/api/job-status/${boundaryJobId}`)
.then(r => r.json())
.then(data => {
const logEl = document.getElementById('boundaryLog');
logEl.textContent = data.logs.join('');
logEl.scrollTop = logEl.scrollHeight;
if (!data.running) {
clearInterval(boundaryLogInterval);
boundaryJobId = null;
document.getElementById('runBoundaryCheckBtn').disabled = false;
loadBoundarySection(true);
}
})
.catch(err => console.error('Boundary check status error:', err));
}
// ---- View switching ----
function setView(view) {
currentView = view;
document.querySelectorAll('.view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === view);
});
document.getElementById('agreementSection').style.display = view === 'agreement' ? '' : 'none';
document.getElementById('cityNameSection').style.display = view === 'city-names' ? '' : 'none';
document.getElementById('boundarySection').style.display = view === 'boundary-check' ? '' : 'none';
document.getElementById('countySelect').closest('.file-input-group').style.display = view === 'boundary-check' ? 'none' : '';
document.getElementById('fieldSelect').closest('.file-input-group').style.display = view === 'boundary-check' ? 'none' : '';
Object.values(statusLayers).forEach(layer => { if (map.hasLayer(layer)) map.removeLayer(layer); });
Object.values(cityLayers).forEach(layer => { if (map.hasLayer(layer)) map.removeLayer(layer); });
Object.values(boundaryFlagLayers).forEach(layer => { if (map.hasLayer(layer)) map.removeLayer(layer); });
if (boundaryOutlineLayer && map.hasLayer(boundaryOutlineLayer)) map.removeLayer(boundaryOutlineLayer);
if (view === 'boundary-check') {
loadBoundarySection();
return;
}
if (!currentGeojson) return;
if (view === 'agreement') {
applyStatusLayerVisibility();
} else {
renderCityLayers(currentGeojson, activeCityField());
}
}
// ---- Data loading ----
function renderGeojson(geojson) {
currentGeojson = geojson;
renderStatusLayers(geojson);
const pinned = (currentCountyId && PINNED_CITIES[currentCountyId]) || [];
buildCityColorMap(geojson, pinned, countyFieldName());
const bounds = L.latLngBounds([]);
geojson.features.forEach(feature => {
const coords = feature.geometry.coordinates;
bounds.extend(L.latLng(coords[1], coords[0]));
});
if (bounds.isValid()) map.fitBounds(bounds, { padding: [50, 50] });
setView(currentView);
}
function showStatus(msg, type) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = `status ${type}`;
}
function hideStatus() {
const el = document.getElementById('status');
el.className = 'status hidden';
}
function loadCounty(countyId) {
if (!countyId) return;
currentCountyId = countyId;
hideStatus();
showStatus(`Loading ${countyId}...`, 'success');
fetch(`/data/${countyId}/city-comparison.geojson`)
.then(resp => {
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
})
.then(geojson => {
renderGeojson(geojson);
hideStatus();
})
.catch(err => {
showStatus(`No city comparison data for ${countyId} yet. Run "Diff vs OSM" then "Generate City Boundaries Report" first. (${err.message})`, 'error');
});
}
document.addEventListener('DOMContentLoaded', function() {
initMap();
initMobileControls();
document.querySelectorAll('#legend .legend-item input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', function() {
const item = this.closest('.legend-item');
const layer = statusLayers[item.dataset.status];
if (!layer) return;
if (this.checked) layer.addTo(map);
else if (map.hasLayer(layer)) map.removeLayer(layer);
});
});
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => setView(btn.dataset.view));
});
document.querySelectorAll('#boundaryLegend .legend-item input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', applyBoundaryLayerVisibility);
});
document.getElementById('runBoundaryCheckBtn').addEventListener('click', runBoundaryCheck);
document.getElementById('citySourceSelect').addEventListener('change', function() {
citySource = this.value;
if (currentGeojson && currentView === 'city-names') {
renderCityLayers(currentGeojson, activeCityField());
}
});
document.getElementById('fieldSelect').addEventListener('change', function() {
compareField = this.value;
if (!currentGeojson) return;
const pinned = (currentCountyId && PINNED_CITIES[currentCountyId]) || [];
buildCityColorMap(currentGeojson, pinned, countyFieldName());
if (currentView === 'agreement') {
renderStatusLayers(currentGeojson);
applyStatusLayerVisibility();
} else {
renderCityLayers(currentGeojson, activeCityField());
}
});
const countySelect = document.getElementById('countySelect');
countySelect.addEventListener('change', function() {
loadCounty(this.value);
});
const firstAvailable = AVAILABLE_COUNTIES && AVAILABLE_COUNTIES.length ? AVAILABLE_COUNTIES[0] : null;
if (firstAvailable) {
countySelect.value = firstAvailable;
loadCounty(firstAvailable);
} else {
showStatus('No counties have city comparison data yet. Run "Diff vs OSM" then "Generate City Boundaries Report" first.', 'error');
}
});
+173
View File
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Boundary Tracer</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="{{ url_for('static', filename='map-ui.css') }}">
<style>
.polygon-item {
display: flex;
align-items: center;
gap: 6px;
margin: 4px 0;
padding: 4px 6px;
background: #f8f9fa;
border-radius: 4px;
font-size: 11px;
}
.polygon-swatch {
width: 11px;
height: 11px;
border-radius: 2px;
flex-shrink: 0;
border: 1px solid rgba(0,0,0,0.25);
}
.polygon-item .polygon-name {
flex: 1;
}
.polygon-item button {
width: auto;
margin: 0;
padding: 2px 7px;
font-size: 10px;
background: #dc3545;
}
.polygon-item button:hover {
background: #b02a37;
}
.draw-hint {
font-size: 10px;
color: #999;
margin: 4px 0 8px;
line-height: 1.4;
}
.btn-secondary {
background: #6c757d !important;
}
.btn-secondary:hover {
background: #565e64 !important;
}
.controls input[type="text"] {
width: 100%;
font-size: 11px;
padding: 4px 6px;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 6px;
box-sizing: border-box;
}
.overlay-row {
display: flex;
gap: 6px;
}
.overlay-row button {
flex: 1;
margin-top: 0;
}
.overlay-opacity-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.overlay-opacity-row label {
font-size: 11px;
color: #666;
white-space: nowrap;
}
.overlay-opacity-row input[type="range"] {
flex: 1;
}
.overlay-warning {
font-size: 10px;
color: #a15c00;
background: #fff3cd;
border-radius: 4px;
padding: 5px 6px;
margin: 4px 0 8px;
line-height: 1.4;
}
</style>
</head>
<body>
<div id="map"></div>
<button class="hamburger-button" id="hamburgerButton" aria-label="Toggle menu">
<span></span>
<span></span>
<span></span>
</button>
<div class="overlay" id="overlay"></div>
<div class="controls" id="controls">
<a href="/" class="btn-back">&#8592; Back</a>
<h3>Boundary Tracer</h3>
<p style="font-size:11px;color:#666;margin-bottom:8px;">
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.
</p>
<h3>Overlay layer (optional)</h3>
<p class="overlay-warning">
This fetches tiles directly from the URL you paste in. Check that
source's Terms of Use permit automated/robotic access first —
thevillages.com's ToS, for example, explicitly prohibit it, so
don't point this at their tile servers.
</p>
<p style="font-size:10px;color:#666;margin:-2px 0 6px;line-height:1.4;">
Accepts a standard <code>{z}/{x}/{y}</code> tile URL, or the base
URL of an ArcGIS tiled MapServer (e.g. <code>.../MapServer</code>)
— its own tiling scheme (including a plain-geographic/WGS84 grid)
is read automatically from that service's metadata.
</p>
<input type="text" id="overlayUrlInput" placeholder="https://.../{z}/{x}/{y}.png or .../MapServer">
<div class="overlay-row">
<button id="addOverlayBtn">Add overlay</button>
<button id="removeOverlayBtn" class="btn-secondary" disabled>Remove</button>
</div>
<div class="overlay-opacity-row" id="overlayOpacityRow" style="display:none;">
<label for="overlayOpacitySlider">Opacity</label>
<input type="range" id="overlayOpacitySlider" min="0" max="100" value="70">
</div>
<button id="newPolygonBtn" style="margin-top:12px;">New polygon</button>
<button id="undoPointBtn" class="btn-secondary" disabled>Undo last point</button>
<button id="finishPolygonBtn" disabled>Finish polygon</button>
<button id="cancelPolygonBtn" class="btn-secondary" disabled>Cancel</button>
<p class="draw-hint" id="drawHint">Click "New polygon" to start tracing.</p>
<h3 style="margin-top:10px;">Polygons</h3>
<div id="polygonList"><p style="font-size:11px;color:#999;">None yet.</p></div>
<button id="saveBtn" style="background:#28a745;margin-top:12px;">Save to server</button>
<button id="downloadBtn" class="btn-secondary">Download GeoJSON</button>
<div id="status" class="status hidden"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="{{ url_for('static', filename='map-ui.js') }}"></script>
<script src="{{ url_for('static', filename='boundary-tracer.js') }}"></script>
</body>
</html>
+243
View File
@@ -0,0 +1,243 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>City Boundaries Report</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="{{ url_for('static', filename='map-ui.css') }}">
<style>
.legend-item {
display: flex;
align-items: center;
margin: 4px 0;
cursor: pointer;
font-size: 11px;
}
.legend-item input[type="checkbox"] {
margin-right: 6px;
}
.legend-swatch {
width: 11px;
height: 11px;
border-radius: 50%;
margin-right: 6px;
flex-shrink: 0;
border: 1px solid rgba(0,0,0,0.25);
}
.legend-label {
flex: 1;
}
.legend-count {
color: #888;
font-variant-numeric: tabular-nums;
}
.no-data-msg {
font-size: 11px;
color: #999;
font-style: italic;
margin-top: 6px;
}
.view-toggle {
display: flex;
gap: 4px;
margin: 6px 0 10px;
}
.view-btn {
flex: 1;
padding: 6px 4px;
font-size: 11px;
font-weight: 600;
border: 1px solid #ccc;
background: #f8f9fa;
color: #555;
border-radius: 4px;
cursor: pointer;
}
.view-btn.active {
background: #6f42c1;
color: white;
border-color: #6f42c1;
}
.view-toggle .view-btn {
flex: 1 1 0;
}
#boundaryLog {
display: none;
max-height: 120px;
overflow-y: auto;
background: #1a1a2e;
color: #ddd;
font-family: monospace;
font-size: 10px;
padding: 6px;
border-radius: 4px;
margin-top: 6px;
white-space: pre-wrap;
}
.boundary-meta {
font-size: 10px;
color: #999;
margin: 4px 0 8px;
}
</style>
</head>
<body>
<div id="map"></div>
<button class="hamburger-button" id="hamburgerButton" aria-label="Toggle menu">
<span></span>
<span></span>
<span></span>
</button>
<div class="overlay" id="overlay"></div>
<div class="controls" id="controls">
<a href="/reports" class="btn-back">&#8592; Back to Reports</a>
<h3>City Boundaries</h3>
<p style="font-size:11px;color:#666;margin-bottom:8px;">
County source data vs matched OSM addresses.
</p>
<div class="file-input-group">
<label for="countySelect" style="display:block;margin-bottom:3px;font-weight:500;font-size:11px;">County:</label>
<select id="countySelect">
{% for county in counties %}
<option value="{{ county.id }}" {% if county.id not in available_counties %}disabled{% endif %}>
{{ county.name }}{% if county.id not in available_counties %} (no data){% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="file-input-group">
<label for="fieldSelect" style="display:block;margin-bottom:3px;font-weight:500;font-size:11px;">Compare:</label>
<select id="fieldSelect">
<option value="postal_community" selected>Postal community (USPS mailing city)</option>
<option value="city">City (legal/municipal jurisdiction)</option>
</select>
<span class="hint" style="font-size:10px;color:#999;">These two often differ — a parcel can be legally inside one city but addressed to another for mail.</span>
</div>
<div class="view-toggle" id="viewToggle">
<button class="view-btn active" data-view="agreement">Agreement Status</button>
<button class="view-btn" data-view="city-names">City Names</button>
<button class="view-btn" data-view="boundary-check">Boundary Check</button>
</div>
<div id="agreementSection">
<h3>Legend</h3>
<div id="legend">
<label class="legend-item" data-status="mismatch">
<input type="checkbox" checked>
<span class="legend-swatch" style="background:#dc3545;"></span>
<span class="legend-label">Mismatch</span>
<span class="legend-count" id="count-mismatch">–</span>
</label>
<label class="legend-item" data-status="county_only">
<input type="checkbox" checked>
<span class="legend-swatch" style="background:#fd7e14;"></span>
<span class="legend-label">Missing in OSM</span>
<span class="legend-count" id="count-county_only">–</span>
</label>
<label class="legend-item" data-status="osm_only">
<input type="checkbox" checked>
<span class="legend-swatch" style="background:#17a2b8;"></span>
<span class="legend-label">Missing in county</span>
<span class="legend-count" id="count-osm_only">–</span>
</label>
<label class="legend-item" data-status="both_missing">
<input type="checkbox" checked>
<span class="legend-swatch" style="background:#6c757d;"></span>
<span class="legend-label">Both missing</span>
<span class="legend-count" id="count-both_missing">–</span>
</label>
<label class="legend-item" data-status="match">
<input type="checkbox">
<span class="legend-swatch" style="background:#28a745;"></span>
<span class="legend-label">Match</span>
<span class="legend-count" id="count-match">–</span>
</label>
</div>
<p style="font-size:10px;color:#999;margin-top:4px;">"Match" is unchecked by default (usually the majority of points) — check it to see agreeing addresses too.</p>
</div>
<div id="cityNameSection" style="display:none;">
<p style="font-size:11px;color:#666;margin-bottom:8px;">
What the source data thinks each address's city is — not an agreement check.
Click any point for the exact name.
</p>
<div class="file-input-group">
<label for="citySourceSelect" style="display:block;margin-bottom:3px;font-weight:500;font-size:11px;">Color by:</label>
<select id="citySourceSelect">
<option value="county" selected>County (source data)</option>
<option value="osm">OpenStreetMap</option>
</select>
</div>
<h3>Cities</h3>
<div id="cityLegend"><!-- filled dynamically --></div>
</div>
<div id="boundarySection" style="display:none;">
<p style="font-size:11px;color:#666;margin-bottom:8px;">
Point-in-polygon check against the traced/imported boundary at
<code>map-checker/boundary.geojson</code> (see the Boundary
Tracer tool) — precise, but only as good as that boundary.
Independent of the county/OSM tag comparison above; spans all
configured counties at once rather than one at a time.
</p>
<button id="runBoundaryCheckBtn">Run boundary check</button>
<div class="boundary-meta" id="boundaryMeta"></div>
<pre id="boundaryLog"></pre>
<h3 style="margin-top:10px;">Legend</h3>
<div id="boundaryLegend">
<label class="legend-item" data-flag="__outline__">
<input type="checkbox" checked>
<span class="legend-swatch" style="background:none;border-color:#6f42c1;border-width:2px;"></span>
<span class="legend-label">Traced boundary</span>
</label>
<label class="legend-item" data-flag="inside_but_not_labeled">
<input type="checkbox" checked>
<span class="legend-swatch" style="background:#dc3545;"></span>
<span class="legend-label">Inside, not labeled expected city</span>
<span class="legend-count" id="count-inside_but_not_labeled">–</span>
</label>
<label class="legend-item" data-flag="outside_but_labeled">
<input type="checkbox" checked>
<span class="legend-swatch" style="background:#fd7e14;"></span>
<span class="legend-label">Outside, labeled expected city</span>
<span class="legend-count" id="count-outside_but_labeled">–</span>
</label>
</div>
<p class="no-data-msg" id="boundaryNoData" style="display:none;">
No boundary check has been run yet — click "Run boundary check" above.
</p>
</div>
<div id="status" class="status hidden"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="{{ url_for('static', filename='map-ui.js') }}"></script>
<script>
const AVAILABLE_COUNTIES = {{ available_counties | tojson }};
const PINNED_CITIES = {{ pinned_cities | tojson }};
const IS_AUTHENTICATED = {{ 'true' if current_user else 'false' }};
</script>
<script src="{{ url_for('static', filename='report-map.js') }}"></script>
</body>
</html>
+9
View File
@@ -361,6 +361,8 @@
<a href="/counties" class="btn-topbar">Counties</a>
<a href="/exceptions" class="btn-topbar">Street Exceptions</a>
<a href="/exclusions" class="btn-topbar">Exclusions</a>
<a href="/reports" class="btn-topbar">Reports</a>
<a href="/map-checker" class="btn-topbar">Boundary Tracer</a>
<a href="/map" class="btn-topbar purple">Open Map Viewer</a>
<div class="topbar-sep"></div>
{% if current_user %}
@@ -488,6 +490,13 @@
</label>
</div>
</div>
<div class="step">
<div class="step-num">5</div>
<div class="step-label">Generate City Boundaries Report</div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('generate-city-report')">Run</button>
</div>
</div>
</div>
</div><!-- /workflows -->
+2 -198
View File
@@ -5,53 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoJSON Map Viewer</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="{{ url_for('static', filename='map-ui.css') }}">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
}
#map {
flex: 1;
width: 100%;
}
.controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
padding: 10px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
min-width: 175px;
max-height: calc(100vh - 20px);
overflow-y: auto;
}
.controls h3 {
margin: 0 0 6px 0;
font-size: 12px;
color: #333;
}
.controls label {
display: flex;
align-items: center;
margin: 4px 0;
cursor: pointer;
font-size: 11px;
}
.layer-item {
display: flex;
align-items: center;
@@ -71,54 +26,6 @@
margin-right: 8px;
}
.controls input[type="checkbox"] {
margin-right: 8px;
}
.controls button {
width: 100%;
padding: 7px;
margin-top: 7px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.controls button:hover {
background: #0056b3;
}
.controls button:disabled {
background: #ccc;
cursor: not-allowed;
}
.status {
margin-top: 7px;
padding: 6px;
border-radius: 4px;
font-size: 11px;
text-align: center;
}
.status.success {
background: #d4edda;
color: #155724;
}
.status.error {
background: #f8d7da;
color: #721c24;
}
.status.hidden {
display: none;
}
.file-input-group {
margin-bottom: 8px;
padding-bottom: 8px;
@@ -160,115 +67,11 @@
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);
}
/* Mobile styles */
@media (max-width: 768px) {
.hamburger-button {
display: flex;
}
.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;
}
/* Crosshair cursor in multi-select mode */
.leaflet-container.multi-select-active,
.leaflet-container.multi-select-active.leaflet-grab {
cursor: crosshair !important;
}
.btn-back {
display: block;
margin-bottom: 12px;
padding: 5px 10px;
background: #1a1a2e;
color: #ddd;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-decoration: none;
text-align: center;
}
.btn-back:hover { background: #2d2d4e; color: #fff; }
</style>
</head>
<body>
@@ -380,6 +183,7 @@
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="{{ url_for('static', filename='map-ui.js') }}"></script>
<script src="{{ url_for('static', filename='map.js') }}"></script>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reports — OSM Import Tools</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #eef0f3;
color: #222;
}
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: #1a1a2e;
}
.topbar h1 {
font-size: 16px;
font-weight: 700;
color: #fff;
margin-right: 4px;
}
.topbar-sep {
width: 1px;
height: 20px;
background: #ffffff30;
}
.btn-topbar {
padding: 5px 12px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
background: #ffffff18;
color: #ddd;
text-decoration: none;
}
.btn-topbar:hover { background: #ffffff30; }
.content {
max-width: 760px;
margin: 0 auto;
padding: 32px 20px;
}
.content h2 {
font-size: 18px;
margin-bottom: 16px;
color: #333;
}
.report-card {
display: block;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.07);
padding: 16px 18px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s;
}
.report-card:hover {
box-shadow: 0 2px 10px rgba(0,0,0,0.12);
}
.report-card h3 {
font-size: 14px;
font-weight: 700;
color: #6f42c1;
margin-bottom: 4px;
}
.report-card p {
font-size: 12px;
color: #666;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="topbar">
<h1>OSM Import Tools</h1>
<div class="topbar-sep"></div>
<a href="/" class="btn-topbar">&#8592; Back</a>
<div style="flex:1"></div>
{% if current_user %}
<span style="font-size:11px;color:#888;">{{ current_user }}</span>
{% endif %}
</div>
<div class="content">
<h2>Reports</h2>
<a class="report-card" href="/reports/city-boundaries">
<h3>City Boundaries</h3>
<p>Checks addr:city agreement between the county source data and OpenStreetMap for
matched addresses, plotted on a map and color-coded by agreement status.</p>
</a>
</div>
</body>
</html>