diff --git a/backend/app/api/cities.py b/backend/app/api/cities.py index 740ebfb..e018728 100644 --- a/backend/app/api/cities.py +++ b/backend/app/api/cities.py @@ -14,12 +14,27 @@ from app.api.deps import get_current_active_user from app.core.security import decode_token from app.services import sse -from app.services.city_resolver import resolve_downtown, get_candidates +from app.services.city_resolver import resolve_downtown, get_candidates, geocode_city logger = logging.getLogger(__name__) router = APIRouter() +@router.get("/geocode") +async def geocode_city_endpoint( + city: str = Query(..., min_length=1), + state: str = Query(..., min_length=2, max_length=2), + current_user: User = Depends(get_current_active_user), +): + """Return geocoded center and administrative polygon for a US city.""" + city_norm = city.strip().title() + state_norm = state.strip().upper() + result = await geocode_city(city_norm, state_norm) + if result is None: + raise HTTPException(status_code=404, detail="City not found") + return result + + @router.get("/candidates") async def get_city_candidates( city: str = Query(..., min_length=1), diff --git a/backend/app/services/city_resolver.py b/backend/app/services/city_resolver.py index a234cb5..90d1404 100644 --- a/backend/app/services/city_resolver.py +++ b/backend/app/services/city_resolver.py @@ -12,119 +12,112 @@ NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" NOMINATIM_HEADERS = {"User-Agent": "ParkingLotApp/1.0 (parking-lot-inference)"} -DOWNTOWN_KEYWORDS = {"downtown", "cbd", "central business", "city center", "urban core"} -BOUNDARY_TITLE_KEYWORDS = {"district", "districts", "boundary", "boundaries", "plan", "zone", "zones", "area", "areas", "neighborhood", "neighbourhoods", "quarter"} -SKIP_SERVICE_KEYWORDS = {"school", "voting", "election", "precinct", "parcel", "address", "building"} +# Keywords for downtown/CBD/mixed-use zoning districts +DOWNTOWN_ZONE_KEYWORDS = { + "downtown", "cbd", "central business", "mixed use", "urban core", + "city center", "central core", +} +# Common zone code prefixes for downtown zones across US cities +DOWNTOWN_ZONE_CODES = {"MX", "CBD", "D1", "D2", "D3", "DT", "UC", "CC"} + +SKIP_SERVICE_KEYWORDS = {"school", "voting", "election", "precinct", "parcel", "address", "building", "buffer", "radius"} + +# For boundary-layer searches (filter_by_zone_keywords=False), the service title must contain +# at least one of these to avoid false positives from POI buffers or unrelated datasets. +BOUNDARY_TITLE_KEYWORDS = { + "downtown", "district", "boundary", "central business", "cbd", "urban core", + "city center", "plan district", "core", "central", +} HUB_API_URL = "https://hub.arcgis.com/api/v3/datasets" +# Zoning-specific field names (checked first) +ZONE_NAME_FIELDS = ( + "zone", "ZONE", + "zone_name", "ZONE_NAME", + "zoning_code", "ZONE_CODE", "ZONING_CODE", + "district_type", "DISTRICT_TYPE", + "zone_type", "ZONE_TYPE", + "zone_class", "ZONE_CLASS", + "zone_desc", "ZONE_DESC", + "land_use", "LAND_USE", + "land_use_code", "LAND_USE_CODE", + "zonedist", "ZONEDIST", + "zone_id", "ZONE_ID", +) +# General name fields (fallback) NAME_FIELDS = ( "name", "NAME", "label", "LABEL", "neighborhood", "NEIGHBORHOOD", "nbhd", "NBHD", - "nbhd_name", "NBHD_NAME", # Denver, many others - "nhood", "NHOOD", # common abbreviation - "l_hood", "L_HOOD", # Seattle large neighborhood - "s_hood", "S_HOOD", # Seattle small neighborhood + "nbhd_name", "NBHD_NAME", + "nhood", "NHOOD", + "l_hood", "L_HOOD", + "s_hood", "S_HOOD", "location", "LOCATION", "district", "DISTRICT", "district_name", "DISTRICT_NAME", "downtown_districts", "DOWNTOWN_DISTRICTS", "area_name", "AREA_NAME", - "comm_name", "COMM_NAME", # community name + "comm_name", "COMM_NAME", "placename", "PLACENAME", ) -def _contains_downtown_keyword(text: str) -> bool: +def _contains_downtown_zone_keyword(text: str) -> bool: + """Return True if text contains a downtown/CBD zoning keyword or code.""" t = text.lower() - return any(kw in t for kw in DOWNTOWN_KEYWORDS) + if any(kw in t for kw in DOWNTOWN_ZONE_KEYWORDS): + return True + t_upper = text.upper() + for code in DOWNTOWN_ZONE_CODES: + if re.search(r'\b' + re.escape(code) + r'\b', t_upper): + return True + return False -def _extract_name(props: dict) -> Optional[str]: - """Pull the best name value out of a feature's properties.""" - for field in NAME_FIELDS: +def _extract_downtown_name(props: dict) -> Optional[str]: + """ + Return the zone name/code if it matches a downtown keyword, else None. + Checks known zoning fields first, then general name fields, then all string props. + """ + all_fields = ZONE_NAME_FIELDS + NAME_FIELDS + for field in all_fields: val = props.get(field) if val and isinstance(val, str) and val.strip(): - return val.strip() - return None - - -async def _try_arcgis(city: str, state: str) -> Optional[Tuple[dict, str]]: - """Search ArcGIS Online for neighborhood boundary feature services. - Returns (geometry, boundary_name) or None.""" - search_url = "https://www.arcgis.com/sharing/rest/search" - params = { - "q": f"{city} {state} neighborhood boundary", - "type": "Feature Service", - "num": 5, - "f": "json", - } - try: - async with httpx.AsyncClient(timeout=20.0) as client: - r = await client.get(search_url, params=params) - r.raise_for_status() - data = r.json() - - results = data.get("results", []) - for item in results[:3]: - url = item.get("url", "") or "" - if not re.match(r"https://services\d*\.arcgis\.com/", url): - continue - # If the service title contains a downtown keyword AND a boundary-type - # keyword, treat all features as matching (e.g. "Downtown_Austin_Plan_Districts"). - # This avoids false positives like "Downtown Austin Tree Canopy". - service_title = item.get("title", "") - title_lower = service_title.lower() - service_is_downtown = ( - _contains_downtown_keyword(service_title) - and any(kw in title_lower for kw in BOUNDARY_TITLE_KEYWORDS) - ) - - query_url = url.rstrip("/") + "/0/query" - try: - async with httpx.AsyncClient(timeout=20.0) as client: - r = await client.get( - query_url, - params={"where": "1=1", "outFields": "*", "returnGeometry": "true", "f": "geojson"}, - ) - r.raise_for_status() - fc = r.json() - except Exception as e: - logger.debug(f"ArcGIS feature fetch failed: {e}") - continue - - # Collect matching features and union them. - # A feature matches if its property values contain a downtown keyword, - # OR the service title itself is a downtown service (union all in that case). - matching_geoms = [] - matching_names = [] - for feature in fc.get("features", []): - props = feature.get("properties", {}) - name_val = " ".join(str(v) for v in props.values() if v) - if service_is_downtown or _contains_downtown_keyword(name_val): - geom = feature.get("geometry") - if geom and geom.get("type") in ("Polygon", "MultiPolygon"): - try: - matching_geoms.append(shape(geom)) - name = _extract_name(props) - if name: - matching_names.append(name) - except Exception: - continue - - if matching_geoms: - merged = unary_union(matching_geoms) - boundary_name = ", ".join(dict.fromkeys(matching_names)) or None - logger.info(f"ArcGIS found {len(matching_geoms)} features in '{url}': {boundary_name}") - return mapping(merged), boundary_name - except Exception as e: - logger.warning(f"ArcGIS search failed: {e}") + name = val.strip() + if _contains_downtown_zone_keyword(name): + return name + # Fallback: check any string property + for val in props.values(): + if val and isinstance(val, str) and val.strip() and len(val.strip()) <= 100: + if _contains_downtown_zone_keyword(val): + return val.strip() return None -async def _fallback_city_buffer(city: str, state: str) -> dict: - """Nominatim city search + 800m buffer around centroid.""" +def _clean_service_title(title: str, downtown_zones: bool = False) -> str: + """Produce a human-readable candidate name from a service/dataset title.""" + # Replace _ and - with spaces + s = re.sub(r'[_\-]', ' ', title) + # Split camelCase on lowercase→uppercase transitions + s = re.sub(r'([a-z])([A-Z])', r'\1 \2', s) + # Normalize whitespace + s = ' '.join(s.split()) + # Strip trailing standalone 4-digit year + s = re.sub(r'\s+\b\d{4}\b$', '', s) + # Title-case + s = s.title() + if downtown_zones: + s += " (downtown zones)" + return s + + +async def _get_city_nominatim(city: str, state: str) -> dict: + """ + Fetch city info from Nominatim. + Returns {"centroid": Point|None, "polygon": GeoJSON dict|None} + """ params = { "q": f"{city}, {state}, United States", "format": "json", @@ -133,6 +126,7 @@ async def _fallback_city_buffer(city: str, state: str) -> dict: "countrycodes": "us", } centroid = None + polygon = None try: async with httpx.AsyncClient(timeout=15.0) as client: r = await client.get(NOMINATIM_URL, params=params, headers=NOMINATIM_HEADERS) @@ -142,46 +136,35 @@ async def _fallback_city_buffer(city: str, state: str) -> dict: geom = results[0].get("geojson", {}) if geom.get("type") in ("Polygon", "MultiPolygon"): centroid = shape(geom).centroid + polygon = geom + if centroid is None: + lat = float(results[0].get("lat", 0)) + lon = float(results[0].get("lon", 0)) + if lat and lon: + centroid = Point(lon, lat) except Exception as e: - logger.warning(f"Nominatim fallback failed: {e}") + logger.warning(f"Nominatim lookup failed: {e}") + return {"centroid": centroid, "polygon": polygon} + + +async def _get_city_centroid(city: str, state: str) -> Optional[Point]: + """Get city centroid from Nominatim.""" + result = await _get_city_nominatim(city, state) + return result["centroid"] + +async def _fallback_city_buffer(city: str, state: str) -> dict: + """Nominatim city search + 800m buffer around centroid.""" + result = await _get_city_nominatim(city, state) + centroid = result["centroid"] if centroid is None: centroid = Point(-98.5795, 39.8283) - centroid_3857 = transform(_to_3857.transform, centroid) buffered_3857 = centroid_3857.buffer(800) buffered_4326 = transform(_to_4326.transform, buffered_3857) return {"type": "Polygon", "coordinates": [list(buffered_4326.exterior.coords)]} -async def _get_city_centroid(city: str, state: str) -> Optional[Point]: - """Get city centroid from Nominatim (lon, lat).""" - params = { - "q": f"{city}, {state}, United States", - "format": "json", - "limit": 1, - "polygon_geojson": 1, - "countrycodes": "us", - } - try: - async with httpx.AsyncClient(timeout=15.0) as client: - r = await client.get(NOMINATIM_URL, params=params, headers=NOMINATIM_HEADERS) - r.raise_for_status() - results = r.json() - if results: - geom = results[0].get("geojson", {}) - if geom.get("type") in ("Polygon", "MultiPolygon"): - return shape(geom).centroid - # Fall back to lat/lon point - lat = float(results[0].get("lat", 0)) - lon = float(results[0].get("lon", 0)) - if lat and lon: - return Point(lon, lat) - except Exception as e: - logger.warning(f"Nominatim centroid lookup failed: {e}") - return None - - def _is_near_city(geom_dict: dict, centroid: Point, max_dist_deg: float = 0.3) -> bool: """Return True if the geometry's centroid is within max_dist_deg of the city centroid.""" try: @@ -191,12 +174,20 @@ def _is_near_city(geom_dict: dict, centroid: Point, max_dist_deg: float = 0.3) - return True # Don't discard if we can't determine -async def _query_service_for_candidates(url: str, service_title: str = "") -> List[dict]: - """Query one ArcGIS FeatureServer URL and return all named polygon features.""" +async def _query_service_for_downtown( + url: str, + service_title: str = "", + filter_by_zone_keywords: bool = True, + score: int = 1, +) -> Optional[dict]: + """ + Query one ArcGIS FeatureServer URL and return a single merged downtown candidate. + + If filter_by_zone_keywords=True (zoning layers): only include features matching + downtown keywords/codes. If False (explicit boundary layers): include all polygon features. + Returns ONE candidate (union of all matching geometries) or None. + """ url = url.rstrip("/") - # If URL already ends with a layer number, append /query directly - # e.g. .../FeatureServer/9 -> .../FeatureServer/9/query - # otherwise default to layer 0 if re.search(r"/\d+$", url): query_url = url + "/query" else: @@ -211,39 +202,41 @@ async def _query_service_for_candidates(url: str, service_title: str = "") -> Li fc = r.json() except Exception as e: logger.debug(f"ArcGIS feature fetch failed for {url}: {e}") - return [] + return None - results = [] + geoms = [] for feature in fc.get("features", []): - props = feature.get("properties", {}) - name = _extract_name(props) - if not name: - continue geom = feature.get("geometry") if not geom or geom.get("type") not in ("Polygon", "MultiPolygon"): continue + if filter_by_zone_keywords: + props = feature.get("properties", {}) + if not _extract_downtown_name(props): + continue try: - area = shape(geom).area + g = shape(geom) + if not g.is_empty: + geoms.append(g) except Exception: continue - # Skip near-zero area features (parcels, point-like polygons) — min ~0.1 km² - if area < 1e-5: - continue - # Skip purely numeric names (e.g. voting precinct "03") and - # bare compass directions (e.g. council quadrant names like "Northeast") - SKIP_NAMES = {"north", "south", "east", "west", "northeast", "northwest", "southeast", "southwest", "central"} - if name.strip().lstrip("0").isdigit() or name.strip() == "0": - continue - if name.strip().lower() in SKIP_NAMES: - continue - title_lower = service_title.lower() - service_is_downtown = ( - _contains_downtown_keyword(service_title) - and any(kw in title_lower for kw in BOUNDARY_TITLE_KEYWORDS) - ) - score = 1 if (service_is_downtown or _contains_downtown_keyword(name)) else 0 - results.append({"name": name, "geometry": geom, "score": score, "_area": area}) - return results + + if not geoms: + return None + + union = unary_union(geoms) + if not union.is_valid: + union = union.buffer(0) + + if union.geom_type == "GeometryCollection": + return None + + name = _clean_service_title(service_title, downtown_zones=filter_by_zone_keywords) + return { + "name": name, + "geometry": mapping(union), + "score": score, + "_area": union.area, + } async def _arcgis_search(query: str, num: int = 5) -> list: @@ -276,10 +269,14 @@ async def _hub_search(query: str, num: int = 5) -> list: return [] -async def _hub_search_and_fetch(query: str, num: int = 5) -> List[dict]: - """Search Hub API and fetch polygon features from matching datasets.""" +async def _hub_search_and_fetch_downtown( + query: str, + filter_by_zone_keywords: bool = True, + score: int = 1, + num: int = 5, +) -> Optional[dict]: + """Search Hub API and return one merged downtown candidate (first-hit wins).""" items = await _hub_search(query, num=num) - best: List[dict] = [] for item in items[:3]: attrs = item.get("attributes", {}) url = (attrs.get("url") or "").strip() @@ -289,31 +286,45 @@ async def _hub_search_and_fetch(query: str, num: int = 5) -> List[dict]: name_lower = dataset_name.lower() if any(kw in name_lower for kw in SKIP_SERVICE_KEYWORDS): continue - batch = await _query_service_for_candidates(url, service_title=dataset_name) - if any(c["score"] >= 1 for c in batch): - return batch # High-confidence hit — stop immediately - if batch and not best: - best = batch - return best + # For explicit boundary searches, require a boundary-related keyword in the title + # to avoid false positives (e.g. YMCA buffers, POI layers) + if not filter_by_zone_keywords and not any(kw in name_lower for kw in BOUNDARY_TITLE_KEYWORDS): + continue + candidate = await _query_service_for_downtown( + url, service_title=dataset_name, + filter_by_zone_keywords=filter_by_zone_keywords, score=score, + ) + if candidate: + return candidate + return None -async def _arcgis_search_and_fetch(query: str, num: int = 5) -> List[dict]: - """Search ArcGIS Online and fetch candidates, preferring score≥1 results.""" +async def _arcgis_search_and_fetch_downtown( + query: str, + filter_by_zone_keywords: bool = True, + score: int = 1, + num: int = 5, +) -> Optional[dict]: + """Search ArcGIS Online and return one merged downtown candidate (first-hit wins).""" results = await _arcgis_search(query, num=num) - best: List[dict] = [] for item in results[:3]: url = item.get("url", "") or "" if not re.match(r"https://services\d*\.arcgis\.com/", url): continue - title_lower = (item.get("title") or "").lower() + title = item.get("title", "") or "" + title_lower = title.lower() if any(kw in title_lower for kw in SKIP_SERVICE_KEYWORDS): continue - batch = await _query_service_for_candidates(url, service_title=item.get("title", "")) - if any(c["score"] >= 1 for c in batch): - return batch # High-confidence hit — stop immediately - if batch and not best: - best = batch # Keep first non-empty as fallback - return best + # For explicit boundary searches, require a boundary-related keyword in the title + if not filter_by_zone_keywords and not any(kw in title_lower for kw in BOUNDARY_TITLE_KEYWORDS): + continue + candidate = await _query_service_for_downtown( + url, service_title=title, + filter_by_zone_keywords=filter_by_zone_keywords, score=score, + ) + if candidate: + return candidate + return None @@ -334,96 +345,126 @@ async def get_candidates( progress_cb: Optional[Callable] = None, ) -> List[dict]: """ - Return all named polygon features from ArcGIS for the given city/state, - ranked by relevance (downtown keywords first, then smaller area first). + Return downtown/CBD polygon candidates for the given city/state, + each representing a merged union of relevant features from one source layer. Strategy: - 1. Hub API: parallel queries to hub.arcgis.com (fast, city-open-data focused). - 2. If no score≥1 hit, fall back to ArcGIS Online search + org-ID discovery. - 3. Geographic filter: keep only features within ~0.75° of city centroid. - 4. Fallback: single 800m buffer entry. + Stage 1: Hub API, explicit boundary layers (score=2) — short-circuit if found + Stage 2: Hub API, zoning union (score=1) — only if Stage 1 empty + Stage 3: ArcGIS Online fallback (both tiers) — only if Stages 1+2 empty + Stage 4: Nominatim city boundary or 800m buffer (score=-1) Each entry: {"name": str, "geometry": GeoJSON dict, "score": int, "source": str} - score=1 → contains downtown keyword, score=0 → other, score=-1 → fallback + score=2 → explicit downtown/boundary layer + score=1 → downtown zones merged from zoning layer + score=-1 → fallback city boundary """ + # Fetch Nominatim centroid first so geographic filtering is available immediately + nominatim_result = await _get_city_nominatim(city, state) + centroid = nominatim_result["centroid"] + + def _geo_filter(cands: List[dict], min_area: float) -> List[dict]: + filtered = [] + for c in cands: + if centroid and not _is_near_city(c["geometry"], centroid): + continue + if c["_area"] < min_area: + continue + filtered.append(c) + return filtered + candidates: List[dict] = [] - # --- Stage 1: Hub API --- + # --- Stage 1: Hub API, explicit boundary layers (score=2) --- await _emit(progress_cb, f"Searching ArcGIS Hub for {city}, {state}...") - hub_queries = [ - f"downtown {city} district", - f"downtown {city} boundary", - f"{city} {state} neighborhood boundary", - f"{city} {state} neighborhoods", - f"{city} {state} village planning", # Phoenix-style urban villages + stage1_queries = [ + f"{city} downtown", + f"{city} downtown plan", + f"{city} central business district", + f"{city} downtown boundary", ] - hub_batches = await asyncio.gather(*[_hub_search_and_fetch(q) for q in hub_queries]) - for batch in hub_batches: - _tag_source(batch, "arcgis_hub") - candidates.extend(batch) - - # --- Geographic filter --- - centroid = await _get_city_centroid(city, state) - if candidates and centroid: - candidates = [c for c in candidates if _is_near_city(c["geometry"], centroid)] - - high_conf = [c for c in candidates if c["score"] >= 1] - if high_conf: - await _emit(progress_cb, f"Found {len(high_conf)} downtown boundary match{'es' if len(high_conf) != 1 else ''}") - elif candidates: - await _emit(progress_cb, f"Found {len(candidates)} nearby area{'s' if len(candidates) != 1 else ''}, no exact downtown match") - - # --- Stage 2: ArcGIS Online search if no high-confidence results --- - if not any(c["score"] >= 1 for c in candidates): - await _emit(progress_cb, "Trying ArcGIS Online search...") - arcgis_queries = [ - f"downtown {city}", - f"{city} {state} downtown district", - f"{city} {state} neighborhood boundary", + stage1_results = await asyncio.gather(*[ + _hub_search_and_fetch_downtown(q, filter_by_zone_keywords=False, score=2) + for q in stage1_queries + ]) + for r in stage1_results: + if r: + r.setdefault("source", "arcgis_hub") + candidates.append(r) + candidates = _geo_filter(candidates, min_area=5e-5) + + # Short-circuit: score=2 found from Hub → skip Stages 2-3 + if not any(c["score"] == 2 for c in candidates): + candidates = [] + + # --- Stage 2: Hub API, zoning union (score=1) --- + await _emit(progress_cb, f"Searching zoning layers for {city}, {state}...") + stage2_queries = [ + f"{city} {state} zoning", + f"{city} {state} land use", + f"{city} zoning districts", ] - arcgis_batches = await asyncio.gather(*[_arcgis_search_and_fetch(q) for q in arcgis_queries]) - for batch in arcgis_batches: - _tag_source(batch, "arcgis_online") - candidates.extend(batch) - - if candidates and centroid: - candidates = [c for c in candidates if _is_near_city(c["geometry"], centroid)] - - # --- Stage 3: org-ID discovery if still no high-confidence results --- - if not any(c["score"] >= 1 for c in candidates): - await _emit(progress_cb, "Discovering city organization datasets...") - broad_results = await _arcgis_search(f"{city} {state}", num=20) - # Count all arcgis.com org IDs — the geographic filter handles off-city results. - # Don't require city name in item title; some city orgs publish with generic titles. - org_counts: dict = {} - for item in broad_results: - url = item.get("url", "") or "" - m = re.search(r"services\d*\.arcgis\.com/([A-Za-z0-9]+)/", url) - if m: - oid = m.group(1) - org_counts[oid] = org_counts.get(oid, 0) + 1 - - top_orgs = sorted(org_counts.items(), key=lambda x: -x[1])[:2] - org_queries = [] - for orgid, _ in top_orgs: - org_queries.append(f"orgid:{orgid} (downtown OR cbd)") - org_queries.append(f"orgid:{orgid} (boundary OR district OR neighborhood)") - - if org_queries: - org_batches = await asyncio.gather(*[_arcgis_search_and_fetch(q, num=10) for q in org_queries]) - for batch in org_batches: - _tag_source(batch, "arcgis_online") - candidates.extend(batch) - - if candidates and centroid: - candidates = [c for c in candidates if _is_near_city(c["geometry"], centroid)] - + stage2_results = await asyncio.gather(*[ + _hub_search_and_fetch_downtown(q, filter_by_zone_keywords=True, score=1) + for q in stage2_queries + ]) + for r in stage2_results: + if r: + r.setdefault("source", "arcgis_hub") + candidates.append(r) + candidates = _geo_filter(candidates, min_area=1e-5) + + # --- Stage 3: ArcGIS Online fallback (only if Stages 1+2 empty) --- + if not candidates: + logger.info(f"Hub API found nothing for {city}, {state}; trying ArcGIS Online") + await _emit(progress_cb, "Trying ArcGIS Online search...") + + # Stage 3 Tier 1: explicit boundary layers + arcgis_t1_queries = [ + f"{city} downtown", + f"{city} downtown plan", + f"{city} central business district", + f"{city} downtown boundary", + ] + arcgis_t1_results = await asyncio.gather(*[ + _arcgis_search_and_fetch_downtown(q, filter_by_zone_keywords=False, score=2) + for q in arcgis_t1_queries + ]) + for r in arcgis_t1_results: + if r: + r.setdefault("source", "arcgis_online") + candidates.append(r) + candidates = _geo_filter(candidates, min_area=5e-5) + + # Stage 3 Tier 2: zoning (only if Tier 1 found nothing) + if not any(c["score"] == 2 for c in candidates): + candidates = [] + arcgis_t2_queries = [ + f"{city} {state} zoning", + f"{city} {state} land use districts", + f"{city} zoning districts", + ] + arcgis_t2_results = await asyncio.gather(*[ + _arcgis_search_and_fetch_downtown(q, filter_by_zone_keywords=True, score=1) + for q in arcgis_t2_queries + ]) + for r in arcgis_t2_results: + if r: + r.setdefault("source", "arcgis_online") + candidates.append(r) + candidates = _geo_filter(candidates, min_area=1e-5) + + # --- Stage 4: Nominatim fallback --- if not candidates: await _emit(progress_cb, "No boundaries found, using approximate downtown area") + city_polygon = nominatim_result["polygon"] + if city_polygon: + name = f"{city}, {state} (city boundary — no zoning data found)" + return [{"name": name, "geometry": city_polygon, "score": -1, "source": "fallback"}] geom = await _fallback_city_buffer(city, state) return [{"name": "Estimated downtown (800m radius)", "geometry": geom, "score": -1, "source": "fallback"}] - # Deduplicate by name, sort: score desc then area asc, return top 10 + # Deduplicate by name, sort score desc then area asc, return top 5 seen: set = set() deduped = [] for c in candidates: @@ -435,19 +476,40 @@ async def get_candidates( deduped.sort(key=lambda c: (-c["score"], c["_area"])) return [ {"name": c["name"], "geometry": c["geometry"], "score": c["score"], "source": c.get("source", "arcgis_hub")} - for c in deduped[:10] + for c in deduped[:5] ] +async def geocode_city(city: str, state: str) -> Optional[dict]: + """ + Return geocoded center and administrative polygon for a US city. + Returns {"center": {"lat": float, "lon": float}, "polygon": GeoJSON dict|None} or None. + """ + result = await _get_city_nominatim(city, state) + centroid = result["centroid"] + if centroid is None: + return None + return { + "center": {"lat": centroid.y, "lon": centroid.x}, + "polygon": result["polygon"], + } + + async def resolve_downtown(city: str, state: str) -> dict: """ Resolve a downtown boundary polygon for the given city/state. Returns {"geometry": GeoJSON Polygon dict, "source": "arcgis"|"fallback", "boundary_name": str|None} """ - result = await _try_arcgis(city, state) - if result: - geom, boundary_name = result - return {"geometry": geom, "source": "arcgis", "boundary_name": boundary_name} + candidates_list = await get_candidates(city, state) + if candidates_list and candidates_list[0]["score"] >= 1: + # Prefer score=2 (explicit boundary) over score=1 (zoning union) + tier1 = [c for c in candidates_list if c["score"] == 2] + use = tier1 if tier1 else [c for c in candidates_list if c["score"] == 1] + geoms = [shape(c["geometry"]) for c in use] + merged = unary_union(geoms) if len(geoms) > 1 else geoms[0] + names = [c["name"] for c in use] + boundary_name = ", ".join(dict.fromkeys(names)) or None + return {"geometry": mapping(merged), "source": "arcgis", "boundary_name": boundary_name} geom = await _fallback_city_buffer(city, state) return {"geometry": geom, "source": "fallback", "boundary_name": None} diff --git a/backend/tests/debug_city.py b/backend/tests/debug_city.py index e29ad23..f8d64ce 100644 --- a/backend/tests/debug_city.py +++ b/backend/tests/debug_city.py @@ -1,11 +1,14 @@ """Debug script: print what ArcGIS returns for a city at each stage.""" import asyncio import sys -import httpx -import re sys.path.insert(0, "/app") -from app.services.city_resolver import _arcgis_search, _query_service_for_candidates, _get_city_centroid, _is_near_city +from app.services.city_resolver import ( + _arcgis_search, + _query_service_for_downtown, + _get_city_centroid, + _is_near_city, +) CITY = sys.argv[1] if len(sys.argv) > 1 else "Denver" STATE = sys.argv[2] if len(sys.argv) > 2 else "CO" @@ -25,39 +28,29 @@ async def main(): centroid = await _get_city_centroid(CITY, STATE) print(f"\nCity centroid: {centroid}") - print("\n--- Querying each result's layer 0 ---") + print("\n--- Querying each result's layer 0 (merged union) ---") for i, r in enumerate(results[:3]): url = r.get("url", "") if not url: continue - print(f"\n[{i}] {r.get('title')!r} — {url}") - batch = await _query_service_for_candidates(url) - print(f" Raw candidates: {len(batch)}") - for c in batch[:5]: - near = _is_near_city(c["geometry"], centroid) if centroid else "?" - print(f" name={c['name']!r} score={c['score']} near={near}") - if len(batch) > 5: - print(f" ... and {len(batch)-5} more") - - # Check what field names the features have (raw fetch for first result) - if results: - url = results[0].get("url", "").rstrip("/") - query_url = (url + "/query") if re.search(r"/\d+$", url) else (url + "/0/query") - print(f"\n--- Raw feature properties from {query_url} ---") - try: - async with httpx.AsyncClient(timeout=20.0) as client: - r = await client.get( - query_url, - params={"where": "1=1", "outFields": "*", "returnGeometry": "false", "f": "geojson", "resultRecordCount": 1}, - ) - fc = r.json() - features = fc.get("features", []) - if features: - print(f" Fields: {list(features[0].get('properties', {}).keys())}") - else: - print(" No features returned") - except Exception as e: - print(f" Error: {e}") + title = r.get("title", "") + print(f"\n[{i}] {title!r} — {url}") + + # Try as boundary layer (no keyword filter) + candidate = await _query_service_for_downtown(url, service_title=title, filter_by_zone_keywords=False) + if candidate: + near = _is_near_city(candidate["geometry"], centroid) if centroid else "?" + print(f" Boundary candidate: name={candidate['name']!r} score={candidate['score']} area={candidate['_area']:.6f} near={near}") + else: + print(f" No boundary candidate (filter_by_zone_keywords=False)") + + # Try as zoning layer (keyword filter) + candidate = await _query_service_for_downtown(url, service_title=title, filter_by_zone_keywords=True) + if candidate: + near = _is_near_city(candidate["geometry"], centroid) if centroid else "?" + print(f" Zoning candidate: name={candidate['name']!r} score={candidate['score']} area={candidate['_area']:.6f} near={near}") + else: + print(f" No zoning candidate (filter_by_zone_keywords=True)") asyncio.run(main()) diff --git a/backend/tests/test_city_resolver.py b/backend/tests/test_city_resolver.py index e329e5c..3c63018 100644 --- a/backend/tests/test_city_resolver.py +++ b/backend/tests/test_city_resolver.py @@ -7,6 +7,7 @@ Run with: cd backend && python -m pytest tests/test_city_resolver.py -v -s """ +import re import pytest from app.services.city_resolver import get_candidates @@ -25,7 +26,9 @@ ] # Smaller cities with no public neighborhood GIS data — fallback is expected and correct -CITIES_FALLBACK_OK: list = [] +CITIES_FALLBACK_OK: list = [ + ("Ketchum", "ID"), +] ALL_CITIES = CITIES_WITH_DATA + CITIES_FALLBACK_OK @@ -59,6 +62,17 @@ async def test_city_candidates_have_valid_geometry(city, state): assert not geom.is_empty, f"{city}, {state}: candidate '{c['name']}' has empty geometry" +@pytest.mark.asyncio +@pytest.mark.parametrize("city,state", CITIES_WITH_DATA) +async def test_no_raw_zone_code_candidate_names(city, state): + """Candidate names should not be raw zone codes like MX-1, CBD-2, DT3.""" + candidates = await get_candidates(city, state) + for c in candidates: + assert not re.match(r'^(MX|CBD|DT|D\d)-?\d*$', c["name"], re.IGNORECASE), ( + f"{city}, {state}: candidate name {c['name']!r} looks like a raw zone code" + ) + + @pytest.mark.asyncio @pytest.mark.parametrize("city,state", CITIES_WITH_DATA) async def test_city_candidates_near_city_center(city, state): diff --git a/frontend/src/components/CreateProjectModal.tsx b/frontend/src/components/CreateProjectModal.tsx index 73b516b..c8b5ca1 100644 --- a/frontend/src/components/CreateProjectModal.tsx +++ b/frontend/src/components/CreateProjectModal.tsx @@ -18,82 +18,103 @@ function sourceLabel(source: string): string { } } -// Component to handle rectangle drawing manually (more reliable than EditControl for rectangles) -function RectangleDrawer({ - onBoundsSelected +// Freehand polygon drawing tool +// Click to add vertices, double-click to finish (3+ points required) +function PolygonDrawer({ + onPolygonSelected }: { - onBoundsSelected: (bounds: { min_lat: number; min_lng: number; max_lat: number; max_lng: number }) => void + onPolygonSelected: (polygon: GeoJSON.Geometry | null) => void }) { const map = useMap() + const isDrawingRef = useRef(false) const [isDrawing, setIsDrawing] = useState(false) - const [startPoint, setStartPoint] = useState(null) - const rectangleRef = useRef(null) - const previewRef = useRef(null) - - useMapEvents({ - mousedown(e) { - if (!isDrawing) return - setStartPoint(e.latlng) - // Create preview rectangle - const startLatLng: L.LatLngTuple = [e.latlng.lat, e.latlng.lng] - previewRef.current = L.rectangle([startLatLng, startLatLng], { + const pointsRef = useRef([]) + const previewLayerRef = useRef(null) + const finishedLayerRef = useRef(null) + + const updatePreview = useCallback(() => { + if (previewLayerRef.current) { + map.removeLayer(previewLayerRef.current) + previewLayerRef.current = null + } + const pts = pointsRef.current + if (pts.length >= 2) { + previewLayerRef.current = L.polyline(pts, { color: '#3388ff', weight: 2, - fillOpacity: 0.2, dashArray: '5, 5', }).addTo(map) - }, - mousemove(e) { - if (!isDrawing || !startPoint || !previewRef.current) return - previewRef.current.setBounds(L.latLngBounds(startPoint, e.latlng)) - }, - mouseup(e) { - if (!isDrawing || !startPoint) return + } + }, [map]) - // Remove preview - if (previewRef.current) { - map.removeLayer(previewRef.current) - previewRef.current = null + useMapEvents({ + click(e) { + if (!isDrawingRef.current) return + pointsRef.current = [...pointsRef.current, e.latlng] + updatePreview() + }, + dblclick() { + if (!isDrawingRef.current) return + // Leaflet fires click before dblclick — remove the last point (added by that click) + const pts = pointsRef.current.slice(0, -1) + if (pts.length < 3) return + + if (previewLayerRef.current) { + map.removeLayer(previewLayerRef.current) + previewLayerRef.current = null } - - // Remove old rectangle - if (rectangleRef.current) { - map.removeLayer(rectangleRef.current) + if (finishedLayerRef.current) { + map.removeLayer(finishedLayerRef.current) } - // Create final rectangle - const bounds = L.latLngBounds(startPoint, e.latlng) - rectangleRef.current = L.rectangle(bounds, { + finishedLayerRef.current = L.polygon(pts, { color: '#3388ff', weight: 2, fillOpacity: 0.3, }).addTo(map) - onBoundsSelected({ - min_lat: bounds.getSouth(), - min_lng: bounds.getWest(), - max_lat: bounds.getNorth(), - max_lng: bounds.getEast(), - }) + const coords = pts.map(p => [p.lng, p.lat]) + coords.push(coords[0]) // close the ring + onPolygonSelected({ type: 'Polygon', coordinates: [coords] }) + pointsRef.current = [] + isDrawingRef.current = false setIsDrawing(false) - setStartPoint(null) map.dragging.enable() }, }) const startDrawing = useCallback(() => { + if (finishedLayerRef.current) { + map.removeLayer(finishedLayerRef.current) + finishedLayerRef.current = null + } + if (previewLayerRef.current) { + map.removeLayer(previewLayerRef.current) + previewLayerRef.current = null + } + pointsRef.current = [] + onPolygonSelected(null) + isDrawingRef.current = true setIsDrawing(true) map.dragging.disable() - }, [map]) + }, [map, onPolygonSelected]) - const clearRectangle = useCallback(() => { - if (rectangleRef.current) { - map.removeLayer(rectangleRef.current) - rectangleRef.current = null + const clearPolygon = useCallback(() => { + if (finishedLayerRef.current) { + map.removeLayer(finishedLayerRef.current) + finishedLayerRef.current = null } - onBoundsSelected(null as unknown as { min_lat: number; min_lng: number; max_lat: number; max_lng: number }) - }, [map, onBoundsSelected]) + if (previewLayerRef.current) { + map.removeLayer(previewLayerRef.current) + previewLayerRef.current = null + } + pointsRef.current = [] + isDrawingRef.current = false + setIsDrawing(false) + onPolygonSelected(null) + map.dragging.enable() + }, [map, onPolygonSelected]) // Add custom control useEffect(() => { @@ -102,10 +123,10 @@ function RectangleDrawer({ const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control') const drawBtn = L.DomUtil.create('a', '', container) - drawBtn.innerHTML = '▢' + drawBtn.innerHTML = '⬠' drawBtn.href = '#' - drawBtn.title = 'Draw rectangle' - drawBtn.style.cssText = 'font-size: 18px; font-weight: bold; display: flex; align-items: center; justify-content: center; width: 30px; height: 30px;' + drawBtn.title = 'Draw polygon — click to add points, double-click to finish' + drawBtn.style.cssText = 'font-size: 16px; font-weight: bold; display: flex; align-items: center; justify-content: center; width: 30px; height: 30px;' L.DomEvent.on(drawBtn, 'click', (e) => { L.DomEvent.preventDefault(e) startDrawing() @@ -114,11 +135,11 @@ function RectangleDrawer({ const clearBtn = L.DomUtil.create('a', '', container) clearBtn.innerHTML = '✕' clearBtn.href = '#' - clearBtn.title = 'Clear rectangle' + clearBtn.title = 'Clear polygon' clearBtn.style.cssText = 'font-size: 14px; font-weight: bold; display: flex; align-items: center; justify-content: center; width: 30px; height: 30px;' L.DomEvent.on(clearBtn, 'click', (e) => { L.DomEvent.preventDefault(e) - clearRectangle() + clearPolygon() }) return container @@ -131,16 +152,12 @@ function RectangleDrawer({ return () => { map.removeControl(control) } - }, [map, startDrawing, clearRectangle]) + }, [map, startDrawing, clearPolygon]) - // Show cursor change when drawing + // Cursor change when drawing useEffect(() => { const container = map.getContainer() - if (isDrawing) { - container.style.cursor = 'crosshair' - } else { - container.style.cursor = '' - } + container.style.cursor = isDrawing ? 'crosshair' : '' }, [isDrawing, map]) return null @@ -182,13 +199,8 @@ export default function CreateProjectModal({ onClose, onCreated }: Props) { const [description, setDescription] = useState('') const [activeTab, setActiveTab] = useState('city') - // Draw tab state - const [bounds, setBounds] = useState<{ - min_lat: number - min_lng: number - max_lat: number - max_lng: number - } | null>(null) + // Draw tab state — uses GeoJSON polygon (same as city tab's bounds_polygon) + const [drawnPolygon, setDrawnPolygon] = useState(null) // City search tab state const [city, setCity] = useState('') @@ -215,9 +227,8 @@ export default function CreateProjectModal({ onClose, onCreated }: Props) { const handleTabChange = (tab: Tab) => { setActiveTab(tab) - // Clear other tab's selection when switching if (tab === 'city') { - setBounds(null) + setDrawnPolygon(null) } else { setBoundsPolygon(null) setCandidates(null) @@ -226,10 +237,6 @@ export default function CreateProjectModal({ onClose, onCreated }: Props) { setError('') } - const handleBoundsSelected = (newBounds: { min_lat: number; min_lng: number; max_lat: number; max_lng: number } | null) => { - setBounds(newBounds) - } - const handleResolveCity = () => { if (!city.trim() || !stateAbbr.trim()) { setResolveError('Please enter both city and state') @@ -294,8 +301,8 @@ export default function CreateProjectModal({ onClose, onCreated }: Props) { return } - if (activeTab === 'draw' && !bounds) { - setError('Please draw a bounding box on the map') + if (activeTab === 'draw' && !drawnPolygon) { + setError('Please draw a polygon on the map') return } @@ -307,19 +314,12 @@ export default function CreateProjectModal({ onClose, onCreated }: Props) { setLoading(true) try { - if (activeTab === 'city' && boundsPolygon) { - await projectsApi.create({ - name: name.trim(), - description: description.trim() || undefined, - bounds_polygon: boundsPolygon, - }) - } else { - await projectsApi.create({ - name: name.trim(), - description: description.trim() || undefined, - bounds: bounds!, - }) - } + const polygon = activeTab === 'city' ? boundsPolygon : drawnPolygon + await projectsApi.create({ + name: name.trim(), + description: description.trim() || undefined, + bounds_polygon: polygon!, + }) onCreated() } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } } } @@ -335,7 +335,9 @@ export default function CreateProjectModal({ onClose, onCreated }: Props) { } } - const submitDisabled = loading || (activeTab === 'draw' ? !bounds : !boundsPolygon) + const submitDisabled = loading || (activeTab === 'draw' ? !drawnPolygon : !boundsPolygon) + + const isFallbackOnly = candidates?.length === 1 && candidates[0].score === -1 return (
- Enter a US city and state abbreviation to find boundary candidates. + Enter a US city and state abbreviation to find downtown zoning boundaries.

)} @@ -478,7 +480,10 @@ export default function CreateProjectModal({ onClose, onCreated }: Props) {
- {candidates.length} boundary {candidates.length === 1 ? 'option' : 'options'} found — hover to preview, click "Use this" to select + {isFallbackOnly + ? 'No zoning data found — showing city boundary' + : `${candidates.length} downtown zone ${candidates.length === 1 ? 'district' : 'districts'} found — hover to preview, click "Use this" to select` + }
+ + {isFallbackOnly && ( +

+ No public zoning data found for this city. The boundary shown is the full city administrative area. + Use the Draw Area tab to trace a specific downtown boundary. +

+ )} +
{/* Left: candidate list */}
@@ -591,7 +604,7 @@ export default function CreateProjectModal({ onClose, onCreated }: Props) { {activeTab === 'draw' && (

- Click the rectangle button, then click and drag on the map to select an area. + Click the polygon button (⬠), then click on the map to add vertices. Double-click to finish.

)} - +
- {bounds && ( + {drawnPolygon && (

- Bounding box selected: {bounds.min_lat.toFixed(4)}, {bounds.min_lng.toFixed(4)} to{' '} - {bounds.max_lat.toFixed(4)}, {bounds.max_lng.toFixed(4)} + Polygon drawn — ready to create project.

)}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0e96ca7..cdfb365 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -56,6 +56,8 @@ export const citiesApi = { api.get('/cities/resolve', { params: { city, state } }).then(r => r.data), candidates: async (city: string, state: string) => api.get('/cities/candidates', { params: { city, state } }).then(r => r.data), + geocode: async (city: string, state: string) => + api.get('/cities/geocode', { params: { city, state } }).then(r => r.data), } // Projects API