Spaces:
Running
Running
File size: 3,680 Bytes
b7c46f4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 | """
Pure-Python geometry for the intel store — GeoJSON Polygon/MultiPolygon only.
Enough for Phase 1 (LGA centroids, which-LGA-is-this-point-in, distances) without
pulling a GIS stack into the backend; PostGIS replaces this when the store moves
to Postgres (ADR-0020).
"""
import math
EARTH_RADIUS_KM = 6371.0088
def _rings(geometry: dict) -> list[list[list[list[float]]]]:
"""Normalises Polygon and MultiPolygon to a list of polygons, each a list of
rings (outer ring first, then holes), each ring a list of [lon, lat]."""
if geometry["type"] == "Polygon":
return [geometry["coordinates"]]
if geometry["type"] == "MultiPolygon":
return geometry["coordinates"]
raise ValueError(f"Unsupported geometry type: {geometry['type']}")
def _ring_area_centroid(ring: list[list[float]]) -> tuple[float, float, float]:
"""Signed area and area-weighted centroid of one ring (shoelace formula)."""
a = cx = cy = 0.0
for (x0, y0), (x1, y1) in zip(ring, ring[1:] + ring[:1]):
cross = x0 * y1 - x1 * y0
a += cross
cx += (x0 + x1) * cross
cy += (y0 + y1) * cross
a /= 2.0
if a == 0:
xs, ys = [p[0] for p in ring], [p[1] for p in ring]
return 0.0, sum(xs) / len(xs), sum(ys) / len(ys)
return a, cx / (6.0 * a), cy / (6.0 * a)
def centroid(geometry: dict) -> tuple[float, float]:
"""Area-weighted centroid as (lon, lat). Holes subtract their area. Planar
maths on degrees is fine at LGA scale this close to the equator."""
total = sx = sy = 0.0
for polygon in _rings(geometry):
for i, ring in enumerate(polygon):
a, cx, cy = _ring_area_centroid([p[:2] for p in ring])
a = abs(a) if i == 0 else -abs(a)
total += a
sx += cx * a
sy += cy * a
if total == 0:
raise ValueError("Degenerate geometry")
return sx / total, sy / total
def bbox(geometry: dict) -> tuple[float, float, float, float]:
xs, ys = [], []
for polygon in _rings(geometry):
for p in polygon[0]:
xs.append(p[0])
ys.append(p[1])
return min(xs), min(ys), max(xs), max(ys)
def _in_ring(lon: float, lat: float, ring: list[list[float]]) -> bool:
inside = False
for (x0, y0), (x1, y1) in zip(ring, ring[1:] + ring[:1]):
if (y0 > lat) != (y1 > lat) and lon < (x1 - x0) * (lat - y0) / (y1 - y0) + x0:
inside = not inside
return inside
def contains(geometry: dict, lon: float, lat: float) -> bool:
for polygon in _rings(geometry):
outer, holes = polygon[0], polygon[1:]
if _in_ring(lon, lat, [p[:2] for p in outer]) and not any(_in_ring(lon, lat, [p[:2] for p in h]) for h in holes):
return True
return False
def haversine_km(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
p1, p2 = math.radians(lat1), math.radians(lat2)
dp, dl = p2 - p1, math.radians(lon2 - lon1)
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(h))
class PolygonIndex:
"""Point-in-polygon lookup over many features, with a bbox prefilter so a
lookup only runs the ray-cast against the handful of polygons whose box
contains the point."""
def __init__(self, features: list[tuple[str, dict]]):
self._items = [(key, geom, bbox(geom)) for key, geom in features]
def lookup(self, lon: float, lat: float) -> str | None:
for key, geom, (x0, y0, x1, y1) in self._items:
if x0 <= lon <= x1 and y0 <= lat <= y1 and contains(geom, lon, lat):
return key
return None
|