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