Compare commits

..

8 commits

Author SHA1 Message Date
Sirius DevOps
12467dab4d
Merge origin/master into CelesTrak satellites API
Keep both overlay_catalog entries: satellites (this PR) and
conflicts (already on master). Disjoint layers.
2026-08-31 22:11:56 -04:00
f072a83ca8 Merge pull request 'feat(map): aircraft popup enrichment and emergency/MIL styling' (#35) from osint-dashboard/t_1d9c5839-osint-map-aircraft-popup-enrichment-emer into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 18s
Reviewed-on: #35
2026-08-31 21:55:09 -04:00
71b6f589a5 Merge branch 'master' into osint-dashboard/t_1d9c5839-osint-map-aircraft-popup-enrichment-emer 2026-08-31 21:54:54 -04:00
af836b0414 Merge pull request 'feat(conflicts): GET /api/conflicts conflict-zone catalog + news/GDELT counts' (#33) from osint-dashboard/t_e66223e3-osint-get-api-conflicts-catalog-news-cou into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 21s
Reviewed-on: #33
2026-08-31 21:52:02 -04:00
9712ad03a7 Merge pull request 'feat(cameras): MDOT MiDrive parser -> cameras table' (#34) from osint-dashboard/t_8be8d860-osint-mdot-midrive-cameras-cameras-table into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 28s
Reviewed-on: #34
2026-08-31 21:50:45 -04:00
Sirius DevOps
6f5c11e8a7 feat(map): aircraft popup enrichment and emergency/MIL styling
Show callsign/hex/registration/type/alt/gs/squawk in ADS-B popups, keep
Planespotters photos, badge and color squawk 7700/7600/7500 plus emergency
flags, and expose a MIL filter only after /api/aircraft sends extra.role.
2026-08-31 21:31:20 -04:00
Sirius DevOps
fbff9e5415 feat(cameras): MDOT MiDrive parser -> cameras table
Add parse_mdot_json for the MDOT MiDrive camera/list JSON where
coordinates/id live in the county field's map link (lat=/lon=/id=) and
the JPEG still lives in the image field's <img src>. Michigan bbox
filter, missing-coords drop, RTSP reject. discovery_source=mdot,
stable source_url keyed on camera id, url_hash dedupe. Wired into
scrape_source dispatch + CAMERA_SOURCE_URLS defaults.

Unit tests: HTML field extract, bbox drop, missing-coords/image drop,
malformed payload.
2026-08-31 21:31:12 -04:00
Sirius DevOps
a82b62a011 feat(conflicts): GET /api/conflicts catalog + news/GDELT event counts
Curated static catalogue of 13 conflict theatres (Ukraine, Gaza, Sudan,
Myanmar, DRC, Yemen, Syria, Lebanon, Sahel, Somalia, Red Sea, Taiwan
Strait, Korean DMZ) with war|high|elevated severity and short factual
descriptions. GET /api/conflicts returns { zones: [{id,label,severity,
lat,lon,description,eventCount,lastUpdated}], timestamp } where
eventCount rolls up pre-existing geocoded news/GDELT//api/news/map rows
inside each zone bbox (0 when the tables are empty). Adds the conflicts
entry to overlay_catalog(). No upstream scraping, no jittered coords.
2026-08-31 21:30:23 -04:00
10 changed files with 671 additions and 21 deletions

View file

@ -16,6 +16,8 @@ CALTRANS_CCTV_URLS = tuple(
f"https://cwwp2.dot.ca.gov/data/d{n}/cctv/cctvStatusD{n:02d}.json" f"https://cwwp2.dot.ca.gov/data/d{n}/cctv/cctvStatusD{n:02d}.json"
for n in range(1, 13) for n in range(1, 13)
) )
# MDOT MiDrive official DOT CCTV list (fields carry rendered HTML).
MDOT_CAMERA_URL = "https://mdotjboss.state.mi.us/MiDrive/camera/list"
_DEFAULT_SOURCE_URL = ",".join(( _DEFAULT_SOURCE_URL = ",".join((
# Publicly published open-camera list (markdown bullets of stream URLs). # Publicly published open-camera list (markdown bullets of stream URLs).
"https://raw.githubusercontent.com/fury999io/public-ip-cams/main/README.md", "https://raw.githubusercontent.com/fury999io/public-ip-cams/main/README.md",
@ -25,6 +27,8 @@ _DEFAULT_SOURCE_URL = ",".join((
"https://raw.githubusercontent.com/willytop8/Live-Environment-Streams/main/streams.geojson", "https://raw.githubusercontent.com/willytop8/Live-Environment-Streams/main/streams.geojson",
# Official Caltrans CWWP2 JPEG + HLS CCTV (districts 112). # Official Caltrans CWWP2 JPEG + HLS CCTV (districts 112).
*CALTRANS_CCTV_URLS, *CALTRANS_CCTV_URLS,
# Official MDOT MiDrive CCTV (JPEG stills, Michigan).
MDOT_CAMERA_URL,
)) ))
CAMERA_SOURCE_URLS = [ CAMERA_SOURCE_URLS = [
u.strip() u.strip()

View file

@ -379,6 +379,79 @@ def parse_caltrans_json(text: str, source_name: str) -> list[dict]:
return out return out
# MDOT MiDrive field extractors (fields carry rendered HTML).
_MDOT_LAT_RE = re.compile(r"lat=(-?\d+(?:\.\d+)?)", re.I)
_MDOT_LON_RE = re.compile(r"lon=(-?\d+(?:\.\d+)?)", re.I)
_MDOT_ID_RE = re.compile(r"[?&]id=(\d+)", re.I)
_MDOT_IMG_RE = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.I)
# Michigan bbox (docs/osiris-ideas.md §3.2): lat 41.648.3, lon -90.5-82.1.
MDOT_LAT_RANGE = (41.6, 48.3)
MDOT_LON_RANGE = (-90.5, -82.1)
def parse_mdot_json(text: str, source_name: str) -> list[dict]:
"""Parse MDOT MiDrive `camera/list` JSON (fields carry rendered HTML).
Coordinates and the stable id live in the `county` field's map link
(`/MiDrive/map?...lat=&lon=&id=`); the `image` field carries an `<img>`
whose src is the JPEG still. Out-of-bbox and coord-less rows are dropped.
"""
try:
payload = json.loads(text)
except (json.JSONDecodeError, ValueError):
return []
if not isinstance(payload, list):
return []
out: list[dict] = []
for row in payload:
if not isinstance(row, dict):
continue
county_html = row.get("county") or ""
m_lat = _MDOT_LAT_RE.search(county_html)
m_lon = _MDOT_LON_RE.search(county_html)
m_id = _MDOT_ID_RE.search(county_html)
if not (m_lat and m_lon and m_id):
continue # missing coordinates / stable id → drop
try:
lat = float(m_lat.group(1))
lon = float(m_lon.group(1))
except ValueError:
continue
if not (MDOT_LAT_RANGE[0] <= lat <= MDOT_LAT_RANGE[1]
and MDOT_LON_RANGE[0] <= lon <= MDOT_LON_RANGE[1]):
continue # out of Michigan bbox → drop
img_m = _MDOT_IMG_RE.search(row.get("image") or "")
if not img_m:
continue
snap = img_m.group(1).strip()
low = snap.lower()
if not (low.startswith("http://") or low.startswith("https://")):
continue
if low.startswith("rtsp"):
continue
cam_id = m_id.group(1)
route = (row.get("route") or "").strip()
loc = (row.get("location") or "").strip().lstrip("@").strip()
county_name = county_html.split("<a", 1)[0].strip()
bits = [
f"{route} @ {loc}" if (route and loc) else (route or loc or None),
county_name or None,
]
name = ", ".join(b for b in bits if b) or None
out.append({
"source_url": f"https://mdotjboss.state.mi.us/MiDrive/camera/{cam_id}",
"snapshot_url": snap,
"discovery_source": "mdot",
"location_lat": lat,
"location_lon": lon,
"location_name": name,
"vendor": "MDOT",
"device_type": "http",
})
return out
def parse_live_streams_geojson(text: str, source_name: str) -> list[dict]: def parse_live_streams_geojson(text: str, source_name: str) -> list[dict]:
"""Parse willytop8/Live-Environment-Streams GeoJSON. """Parse willytop8/Live-Environment-Streams GeoJSON.
@ -490,6 +563,8 @@ async def scrape_source(client: RateLimitedClient, geo: Geocoder,
body = resp.text body = resp.text
if "cwwp2.dot.ca.gov" in src_url or "cctvStatus" in src_url: if "cwwp2.dot.ca.gov" in src_url or "cctvStatus" in src_url:
cams = parse_caltrans_json(body, name) cams = parse_caltrans_json(body, name)
elif "mdotjboss.state.mi.us" in src_url or "/MiDrive/camera/list" in src_url:
cams = parse_mdot_json(body, name)
elif ("getCameraDataByLoc" in src_url elif ("getCameraDataByLoc" in src_url
or ("json" in ctype and '"locs"' in body[:4000] and '"cams"' in body[:8000])): or ("json" in ctype and '"locs"' in body[:4000] and '"cams"' in body[:8000])):
cams = parse_alertwest_json(body, name) cams = parse_alertwest_json(body, name)

168
app/conflicts.py Normal file
View file

@ -0,0 +1,168 @@
"""Curated OSINT conflict-zone catalog + point-in-bbox event counting.
A static, human-curated list of active conflict theatres (war / high /
elevated). Purely descriptive this is a catalog, not a live feed and not a
scrape of LiveUAMap or any other source. Severity and descriptions are
editorial judgement kept short and factual.
Each zone carries an internal ``bbox`` (``min_lat, min_lon, max_lat, max_lon``)
used only to count pre-existing geocoded news/GDELT/``/api/news/map`` rows that
fall inside it. The bbox is not part of the API response; callers get the
``eventCount`` roll-up instead.
Never call an upstream API from here event counts come from rows already in
the local database (``events`` with geocoords + ``news_items`` map pins).
"""
from __future__ import annotations
from datetime import datetime
# id → zone. ``lat``/``lon`` is the fly-to anchor; ``bbox`` is the internal
# count window in ``min_lat, min_lon, max_lat, max_lon`` order.
_ZONES: tuple[dict, ...] = (
{
"id": "ukraine",
"label": "Ukraine",
"severity": "war",
"lat": 48.5,
"lon": 31.0,
"description": "Full-scale Russian invasion since 2022; active front lines in the east and south.",
"bbox": (44.3, 22.1, 52.4, 40.2),
},
{
"id": "gaza",
"label": "Gaza",
"severity": "war",
"lat": 31.4,
"lon": 34.4,
"description": "IsraelHamas war; sustained fighting and a severe humanitarian crisis in the Gaza Strip.",
"bbox": (31.0, 34.1, 31.8, 34.7),
},
{
"id": "sudan",
"label": "Sudan",
"severity": "war",
"lat": 15.5,
"lon": 30.0,
"description": "Civil war between the SAF and RSF since 2023, with mass displacement across the country.",
"bbox": (8.7, 21.8, 22.0, 38.6),
},
{
"id": "myanmar",
"label": "Myanmar",
"severity": "war",
"lat": 21.5,
"lon": 96.0,
"description": "Post-2021 coup conflict pitting the junta against resistance and ethnic armed groups.",
"bbox": (9.5, 92.2, 28.5, 101.2),
},
{
"id": "drc",
"label": "DR Congo",
"severity": "war",
"lat": -1.5,
"lon": 28.0,
"description": "Eastern DRC conflict involving M23 and other armed groups; heavy displacement around Goma.",
"bbox": (-5.0, 26.0, 3.0, 31.0),
},
{
"id": "yemen",
"label": "Yemen",
"severity": "war",
"lat": 15.5,
"lon": 47.5,
"description": "Protracted Houthigovernment/coalition war with one of the world's worst humanitarian emergencies.",
"bbox": (12.6, 42.5, 19.0, 54.0),
},
{
"id": "syria",
"label": "Syria",
"severity": "war",
"lat": 34.5,
"lon": 38.5,
"description": "Multi-sided civil war; government, opposition, and external actors continue to engage.",
"bbox": (32.3, 35.7, 37.3, 42.4),
},
{
"id": "lebanon",
"label": "Lebanon",
"severity": "high",
"lat": 33.9,
"lon": 35.9,
"description": "IsraelHezbollah hostilities with periodic escalation along the southern border.",
"bbox": (33.0, 35.0, 34.7, 36.6),
},
{
"id": "sahel",
"label": "Sahel",
"severity": "high",
"lat": 14.5,
"lon": 0.0,
"description": "Jihadist insurgencies across Mali, Burkina Faso, and Niger destabilising the central Sahel.",
"bbox": (10.0, -10.0, 20.0, 12.0),
},
{
"id": "somalia",
"label": "Somalia",
"severity": "high",
"lat": 6.0,
"lon": 45.0,
"description": "Al-Shabaab insurgency against the federal government and security forces.",
"bbox": (-2.0, 41.0, 12.0, 51.5),
},
{
"id": "red_sea",
"label": "Red Sea",
"severity": "high",
"lat": 18.0,
"lon": 40.0,
"description": "Houthi attacks on commercial shipping transiting the Red Sea corridor.",
"bbox": (12.0, 34.0, 22.0, 44.0),
},
{
"id": "taiwan_strait",
"label": "Taiwan Strait",
"severity": "elevated",
"lat": 24.5,
"lon": 119.5,
"description": "Heightened military standoff between China and Taiwan, including deterrence patrols.",
"bbox": (21.9, 117.0, 26.5, 122.0),
},
{
"id": "korean_dmz",
"label": "Korean DMZ",
"severity": "elevated",
"lat": 38.3,
"lon": 127.0,
"description": "Heavily fortified inter-Korean border with periodic tensions and military drills.",
"bbox": (37.5, 126.0, 39.0, 128.5),
},
)
SEVERITIES: frozenset[str] = frozenset({"war", "high", "elevated"})
def conflict_zones() -> list[dict]:
"""Return a fresh shallow copy of the catalog (callers must not mutate)."""
return [dict(z) for z in _ZONES]
def zone_event_stats(
points: list[tuple[float, float, datetime | None]],
bbox: tuple[float, float, float, float],
) -> tuple[int, datetime | None]:
"""Count points inside ``bbox`` and return (count, latest timestamp).
``points`` is an iterable of ``(lat, lon, ts)``; ``ts`` may be ``None``.
``bbox`` is ``(min_lat, min_lon, max_lat, max_lon)``.
"""
min_lat, min_lon, max_lat, max_lon = bbox
count = 0
latest: datetime | None = None
for lat, lon, ts in points:
if min_lat <= lat <= max_lat and min_lon <= lon <= max_lon:
count += 1
if ts is not None and (latest is None or ts > latest):
latest = ts
return count, latest

View file

@ -156,6 +156,12 @@ def overlay_catalog() -> dict:
"endpoint": "/api/satellites", "endpoint": "/api/satellites",
"attribution": "CelesTrak (GP JSON / SGP4)", "attribution": "CelesTrak (GP JSON / SGP4)",
}, },
"conflicts": {
"id": "conflicts",
"kind": "points",
"endpoint": "/api/conflicts",
"attribution": "Curated OSINT conflict catalog",
},
} }

View file

@ -38,6 +38,7 @@ from models import (
) )
from schemas import ( from schemas import (
AlertCreate, AlertOut, AlertSeverity, AlertType, AlertUpdate, AlertCreate, AlertOut, AlertSeverity, AlertType, AlertUpdate,
ConflictZoneOut, ConflictsOut,
DashboardSummary, EntityCreate, EntityKind, EntityOut, DashboardSummary, EntityCreate, EntityKind, EntityOut,
EventCreate, EventOut, FireOut, NewsArticleOut, NewsMapItemOut, EventCreate, EventOut, FireOut, NewsArticleOut, NewsMapItemOut,
NewsSummaryOut, NewsTickerItemOut, NewsSummaryOut, NewsTickerItemOut,
@ -1433,6 +1434,58 @@ async def map_chokepoints():
return {"chokepoints": chokepoints()} return {"chokepoints": chokepoints()}
async def _fetch_geocoded_points() -> list[tuple[float, float, datetime | None]]:
"""Collect geocoded ``(lat, lon, ts)`` rows from the local DB.
Sources are the flagged map pins (``news_items`` kind=map) and geocoded
news/GDELT events (``events`` with ``location_lat/lon``). This is the
pre-existing geocoded corpus the conflict-zone counters roll up no
upstream scraping and no generated/jittered coordinates.
"""
async with async_session() as session:
map_rows = (
await session.execute(
select(news_items.c.lat, news_items.c.lon, news_items.c.created_at)
.where(
news_items.c.kind == "map",
news_items.c.lat.isnot(None),
news_items.c.lon.isnot(None),
)
)
).all()
event_rows = (
await session.execute(
select(events.c.location_lat, events.c.location_lon, events.c.source_timestamp)
.where(
events.c.source_type.in_(["rss", "gdel-t2"]),
events.c.location_lat.isnot(None),
events.c.location_lon.isnot(None),
)
)
).all()
return [tuple(r) for r in map_rows] + [tuple(r) for r in event_rows]
@app.get("/api/conflicts", response_model=ConflictsOut)
async def list_conflicts():
"""Curated conflict-zone catalog with per-zone event counts.
Static catalogue (severity + short factual description) merged with a live
``eventCount`` roll-up of pre-existing geocoded news/GDELT//api/news/map
rows inside each zone bbox. Empty DB ``eventCount=0`` (never 502).
"""
from conflicts import conflict_zones, zone_event_stats
points = await _fetch_geocoded_points()
timestamp = datetime.now(timezone.utc)
zones = []
for z in conflict_zones():
bbox = z.pop("bbox")
count, latest = zone_event_stats(points, bbox)
zones.append({**z, "eventCount": count, "lastUpdated": latest})
return {"zones": zones, "timestamp": timestamp}
def _upstream_or_502(exc: Exception, name: str) -> NoReturn: def _upstream_or_502(exc: Exception, name: str) -> NoReturn:
logger.warning("live_layer_upstream_failed", layer=name, error=str(exc)) logger.warning("live_layer_upstream_failed", layer=name, error=str(exc))
raise HTTPException(502, f"{name} upstream unavailable: {exc}") from exc raise HTTPException(502, f"{name} upstream unavailable: {exc}") from exc

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Optional from typing import Literal, Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
@ -386,3 +386,24 @@ class GeofenceUpdate(BaseModel):
geojson: Optional[dict] = None geojson: Optional[dict] = None
active: Optional[bool] = None active: Optional[bool] = None
class ConflictZoneOut(BaseModel):
"""One curated conflict theatre as exposed by GET /api/conflicts."""
id: str
label: str
severity: Literal["war", "high", "elevated"]
lat: float
lon: float
description: str
eventCount: int
lastUpdated: Optional[datetime] = None
class ConflictsOut(BaseModel):
"""Response envelope for GET /api/conflicts."""
zones: list[ConflictZoneOut]
timestamp: datetime

View file

@ -236,6 +236,16 @@
filter: drop-shadow(0 0 4px currentColor) drop-shadow(0 1px 2px rgba(0,0,0,0.8)); filter: drop-shadow(0 0 4px currentColor) drop-shadow(0 1px 2px rgba(0,0,0,0.8));
} }
.hdg-glyph svg { width: 100%; height: 100%; display: block; } .hdg-glyph svg { width: 100%; height: 100%; display: block; }
.hdg-emerg {
animation: emerg-pulse 1.15s ease-in-out infinite;
}
@keyframes emerg-pulse {
0%, 100% { filter: drop-shadow(0 0 4px #ff5d5d); }
50% { filter: drop-shadow(0 0 12px #ff5d5d) drop-shadow(0 0 18px #ff5d5d); }
}
@media (prefers-reduced-motion: reduce) {
.hdg-emerg, .role-badge.emergency { animation: none; }
}
/* ── Camera marker clusters (neon green, matches the dots) ──── */ /* ── Camera marker clusters (neon green, matches the dots) ──── */
.marker-cluster-small, .marker-cluster-medium, .marker-cluster-large { .marker-cluster-small, .marker-cluster-medium, .marker-cluster-large {
@ -334,6 +344,7 @@
.lp-dot.news { background: var(--magenta); box-shadow: 0 0 7px var(--magenta); border-radius: 1px; transform: rotate(45deg); } .lp-dot.news { background: var(--magenta); box-shadow: 0 0 7px var(--magenta); border-radius: 1px; transform: rotate(45deg); }
.lp-dot.weather { background: var(--amber); box-shadow: 0 0 7px var(--amber); } .lp-dot.weather { background: var(--amber); box-shadow: 0 0 7px var(--amber); }
.lp-dot.flights { background: #7dd3fc; box-shadow: 0 0 7px #7dd3fc; } .lp-dot.flights { background: #7dd3fc; box-shadow: 0 0 7px #7dd3fc; }
.lp-dot.mil { background: #f472b6; box-shadow: 0 0 7px #f472b6; }
.lp-dot.vessels { background: #2dd4bf; box-shadow: 0 0 7px #2dd4bf; } .lp-dot.vessels { background: #2dd4bf; box-shadow: 0 0 7px #2dd4bf; }
.lp-dot.radar { background: #38bdf8; box-shadow: 0 0 7px #38bdf8; } .lp-dot.radar { background: #38bdf8; box-shadow: 0 0 7px #38bdf8; }
.lp-dot.thermal { background: #f97316; box-shadow: 0 0 7px #f97316; } .lp-dot.thermal { background: #f97316; box-shadow: 0 0 7px #f97316; }
@ -393,6 +404,10 @@
.role-badge.civilian { color: #7dd3fc; border: 1px solid #38bdf8; background: rgba(56,189,248,0.1); } .role-badge.civilian { color: #7dd3fc; border: 1px solid #38bdf8; background: rgba(56,189,248,0.1); }
.role-badge.government { color: #facc15; border: 1px solid #facc15; background: rgba(250,204,21,0.12); } .role-badge.government { color: #facc15; border: 1px solid #facc15; background: rgba(250,204,21,0.12); }
.role-badge.firefighter { color: #fb923c; border: 1px solid #fb923c; background: rgba(251,146,60,0.12); } .role-badge.firefighter { color: #fb923c; border: 1px solid #fb923c; background: rgba(251,146,60,0.12); }
.role-badge.emergency {
color: #ff5d5d; border: 1px solid #ff5d5d; background: rgba(255,93,93,0.16);
animation: emerg-pulse 1.4s ease-in-out infinite;
}
.blip-pop { min-width: 200px; max-width: 260px; } .blip-pop { min-width: 200px; max-width: 260px; }
.blip-pop .blip-src { font-family: 'Share Tech Mono', monospace; font-size: 0.62rem; color: var(--cyan); text-transform: uppercase; letter-spacing: 0.08em; } .blip-pop .blip-src { font-family: 'Share Tech Mono', monospace; font-size: 0.62rem; color: var(--cyan); text-transform: uppercase; letter-spacing: 0.08em; }
.blip-pop .blip-time { font-size: 0.7rem; color: var(--muted); font-family: 'Share Tech Mono', monospace; margin: 0.2rem 0 0.3rem; } .blip-pop .blip-time { font-size: 0.7rem; color: var(--muted); font-family: 'Share Tech Mono', monospace; margin: 0.2rem 0 0.3rem; }
@ -893,7 +908,11 @@
<label class="lp-name"><input type="checkbox" id="lp-ac-on" checked onchange="toggleAircraft()"> <span class="lp-dot flights"></span> Aircraft</label> <label class="lp-name"><input type="checkbox" id="lp-ac-on" checked onchange="toggleAircraft()"> <span class="lp-dot flights"></span> Aircraft</label>
<span class="lp-count" id="lp-ac-count">0</span> <span class="lp-count" id="lp-ac-count">0</span>
</div> </div>
<div class="lp-note">Magenta = military · orange = firefighting · else altitude. Click a plane for type / squawk / operator.</div> <div class="lp-row" id="lp-ac-mil-row" hidden>
<label class="lp-name"><input type="checkbox" id="lp-ac-mil-on" onchange="toggleAircraftMil()"> <span class="lp-dot mil"></span> MIL</label>
<span class="lp-count" id="lp-ac-mil-count"></span>
</div>
<div class="lp-note">Magenta = military · orange = firefighting · red = emergency (7700/7600/7500) · else altitude. Click a plane for type / squawk / operator.</div>
</div> </div>
<div class="lp-layer"> <div class="lp-layer">
<div class="lp-row"> <div class="lp-row">
@ -1919,7 +1938,7 @@ let sentinelItemId = null, sentinelBounds = null;
let wxAlertsGroup = null, wxAlertsOn = true; let wxAlertsGroup = null, wxAlertsOn = true;
let perimGroup = null, perimOn = true; let perimGroup = null, perimOn = true;
let incidentsGroup = null, incidentsOn = false; let incidentsGroup = null, incidentsOn = false;
let acGroup = null, acOn = true; let acGroup = null, acOn = true, acMilOn = false, acMilSupported = false;
let trainsGroup = null, trainsOn = true; let trainsGroup = null, trainsOn = true;
let vesselsGroup = null, vesselsOn = false; let vesselsGroup = null, vesselsOn = false;
let stormsGroup = null, stormsOn = true; let stormsGroup = null, stormsOn = true;
@ -1950,6 +1969,14 @@ function sendLiveViewport() {
if (!liveWs || liveWs.readyState !== 1 || !map) return; if (!liveWs || liveWs.readyState !== 1 || !map) return;
liveWs.send(JSON.stringify({ type: 'viewport', bbox: currentBBox() })); liveWs.send(JSON.stringify({ type: 'viewport', bbox: currentBBox() }));
} }
function dropLivePoint(group, id) {
if (!group || !group._osintById || id == null) return;
const key = String(id);
const m = group._osintById.get(key);
if (!m) return;
group.removeLayer(m);
group._osintById.delete(key);
}
function upsertLivePoint(group, p, colorFn, feed) { function upsertLivePoint(group, p, colorFn, feed) {
if (!map || !p || p.id == null || p.lat == null || p.lon == null) return group; if (!map || !p || p.id == null || p.lat == null || p.lon == null) return group;
if (!group || !map.hasLayer(group) || !group._osintById) { if (!group || !map.hasLayer(group) || !group._osintById) {
@ -1960,7 +1987,7 @@ function upsertLivePoint(group, p, colorFn, feed) {
let m = group._osintById.get(id); let m = group._osintById.get(id);
if (m) { if (m) {
m.setLatLng([p.lat, p.lon]); m.setLatLng([p.lat, p.lon]);
if (feed) m.setIcon(feedIcon(feed, col, p.heading)); if (feed) m.setIcon(feedIcon(feed, col, p.heading, feedPulse(feed, p)));
else if (m.setStyle) m.setStyle({ color: col, fillColor: col }); else if (m.setStyle) m.setStyle({ color: col, fillColor: col });
} else { } else {
m = makePointMarker(p, colorFn, feed, pointCanvas()); m = makePointMarker(p, colorFn, feed, pointCanvas());
@ -1976,24 +2003,48 @@ function applyLiveMarker(kind, p) {
return sog > 0.5 ? '#2dd4bf' : '#64748b'; return sog > 0.5 ? '#2dd4bf' : '#64748b';
}, 'vessel'); }, 'vessel');
} else if (kind === 'adsb' && acOn && map && map.getZoom() > 3 && !dvrTs) { } else if (kind === 'adsb' && acOn && map && map.getZoom() > 3 && !dvrTs) {
if (!acVisible(p)) {
dropLivePoint(acGroup, p.id);
return;
}
acGroup = upsertLivePoint(acGroup, p, q => acColor(q), 'ac'); acGroup = upsertLivePoint(acGroup, p, q => acColor(q), 'ac');
} else if (kind === 'geofence_alert') { } else if (kind === 'geofence_alert') {
showGeofenceToast(p); showGeofenceToast(p);
} else if (kind === 'fire_aircraft') { } else if (kind === 'fire_aircraft') {
firefighterHex.add(String(p.aircraft_hex || '')); firefighterHex.add(String(p.aircraft_hex || ''));
if (acOn && p.aircraft_lat != null) { if (acOn && p.aircraft_lat != null) {
acGroup = upsertLivePoint(acGroup, { const row = {
id: p.aircraft_hex, lat: p.aircraft_lat, lon: p.aircraft_lon, id: p.aircraft_hex, lat: p.aircraft_lat, lon: p.aircraft_lon,
label: p.label || p.aircraft_hex, extra: { type: p.aircraft_type, firefighter: true, hex: p.aircraft_hex, src: 'adsb.lol' }, label: p.label || p.aircraft_hex, extra: { type: p.aircraft_type, firefighter: true, hex: p.aircraft_hex, src: 'adsb.lol' },
}, q => acColor(q), 'ac'); };
if (!acVisible(row)) dropLivePoint(acGroup, row.id);
else acGroup = upsertLivePoint(acGroup, row, q => acColor(q), 'ac');
} }
} }
} }
const EMERG_SQUAWK = new Set(['7700', '7600', '7500']);
function acIsEmergency(p) {
const extra = (p && p.extra) || {};
const em = String(extra.emergency || '').toLowerCase();
if (em && em !== 'none') return true;
const sq = String(extra.squawk || '').replace(/\s/g, '');
return EMERG_SQUAWK.has(sq);
}
function acVisible(p) {
if (!acMilOn) return true;
return ((p && p.extra) || {}).role === 'military';
}
function noteMilSupport(pts) {
if (acMilSupported) return;
if (!Array.isArray(pts) || !pts.some(q => q && q.extra && q.extra.role)) return;
acMilSupported = true;
const row = document.getElementById('lp-ac-mil-row');
if (row) row.hidden = false;
}
function acColor(p) { function acColor(p) {
const extra = p.extra || {}; const extra = p.extra || {};
const t = String(extra.type || '').toUpperCase(); const t = String(extra.type || '').toUpperCase();
const em = String(extra.emergency || '').toLowerCase(); if (acIsEmergency(p)) return '#ff5d5d';
if (em && em !== 'none') return '#ff5d5d';
if (extra.firefighter || firefighterHex.has(String(p.id)) || FF_ICAO.has(t)) return '#fb923c'; if (extra.firefighter || firefighterHex.has(String(p.id)) || FF_ICAO.has(t)) return '#fb923c';
if (extra.role === 'military') return '#f472b6'; if (extra.role === 'military') return '#f472b6';
return altColor(extra.alt_baro); return altColor(extra.alt_baro);
@ -2822,9 +2873,13 @@ function pointPopup(p) {
const extra = p.extra || {}; const extra = p.extra || {};
const src = extra.src || ''; const src = extra.src || '';
const role = extra.firefighter ? 'firefighter' : extra.role; const role = extra.firefighter ? 'firefighter' : extra.role;
const badge = role let badge = '';
? ` <span class="role-badge ${esc(role)}">${esc(String(role).toUpperCase())}</span>` if (src === 'adsb.lol' && acIsEmergency(p)) {
: ''; badge += ' <span class="role-badge emergency">EMERGENCY</span>';
}
if (role) {
badge += ` <span class="role-badge ${esc(role)}">${esc(String(role).toUpperCase())}</span>`;
}
const rows = []; const rows = [];
const add = (k, v) => { const add = (k, v) => {
const val = _popVal(v); const val = _popVal(v);
@ -2832,10 +2887,12 @@ function pointPopup(p) {
rows.push(`<tr><td class="k">${esc(k)}</td><td>${esc(val)}</td></tr>`); rows.push(`<tr><td class="k">${esc(k)}</td><td>${esc(val)}</td></tr>`);
}; };
if (src === 'adsb.lol') { if (src === 'adsb.lol') {
add('callsign', p.label);
add('hex', extra.hex);
add('registration', extra.reg);
add('type', extra.type); add('type', extra.type);
add('aircraft', extra.desc); add('aircraft', extra.desc);
add('operator', extra.ownOp); add('operator', extra.ownOp);
add('reg', extra.reg);
const alt = extra.alt_baro; const alt = extra.alt_baro;
add('alt', alt != null ? `${alt} ft` : null); add('alt', alt != null ? `${alt} ft` : null);
add('vs', extra.vs != null ? `${extra.vs} fpm` : null); add('vs', extra.vs != null ? `${extra.vs} fpm` : null);
@ -2844,7 +2901,6 @@ function pointPopup(p) {
add('squawk', extra.squawk); add('squawk', extra.squawk);
add('emergency', extra.emergency); add('emergency', extra.emergency);
add('class', extra.emitter); add('class', extra.emitter);
add('hex', extra.hex);
} else if (src === 'aisstream') { } else if (src === 'aisstream') {
add('kind', extra.kind); add('kind', extra.kind);
add('flag', extra.country); add('flag', extra.country);
@ -2943,20 +2999,24 @@ function sanitizeColor(c, fallback) {
if (/^rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(,\s*[\d.]+\s*)?\)$/.test(s)) return s; if (/^rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(,\s*[\d.]+\s*)?\)$/.test(s)) return s;
return fallback; return fallback;
} }
function feedIcon(feed, color, heading) { function feedPulse(feed, p) {
return feed === 'ac' && acIsEmergency(p);
}
function feedIcon(feed, color, heading, pulse) {
// Normalize heading to [0,360) integer so the cache stays bounded. // Normalize heading to [0,360) integer so the cache stays bounded.
// Missing/empty/NaN heading -> -1 sentinel -> glyph rendered upright. // Missing/empty/NaN heading -> -1 sentinel -> glyph rendered upright.
const hnum = Number(heading); const hnum = Number(heading);
const h = (heading === null || heading === '' || heading === undefined || !Number.isFinite(hnum)) const h = (heading === null || heading === '' || heading === undefined || !Number.isFinite(hnum))
? -1 ? -1
: (Math.round(hnum % 360) + 360) % 360; : (Math.round(hnum % 360) + 360) % 360;
const key = `${feed}|${color}|${h}`; const key = `${feed}|${color}|${h}|${pulse ? 1 : 0}`;
let ic = feedIconCache.get(key); let ic = feedIconCache.get(key);
if (!ic) { if (!ic) {
const rot = h >= 0 ? `transform:rotate(${h}deg);` : ''; const rot = h >= 0 ? `transform:rotate(${h}deg);` : '';
const pulseCls = pulse ? ' hdg-emerg' : '';
ic = L.divIcon({ ic = L.divIcon({
className: '', className: '',
html: `<span class="hdg-marker"><span class="hdg-glyph hdg-${feed}" style="color:${color};${rot}">${FEED_GLYPHS[feed]}</span></span>`, html: `<span class="hdg-marker${pulseCls}"><span class="hdg-glyph hdg-${feed}" style="color:${color};${rot}">${FEED_GLYPHS[feed]}</span></span>`,
iconSize: [26, 26], iconAnchor: [13, 13], iconSize: [26, 26], iconAnchor: [13, 13],
}); });
feedIconCache.set(key, ic); feedIconCache.set(key, ic);
@ -2969,7 +3029,7 @@ function makePointMarker(p, colorFn, feed, renderer) {
let m; let m;
if (feed) { if (feed) {
const heading = Number(p.heading); const heading = Number(p.heading);
const icon = feedIcon(feed, col, Number.isNaN(heading) ? null : heading); const icon = feedIcon(feed, col, Number.isNaN(heading) ? null : heading, feedPulse(feed, p));
m = L.marker([p.lat, p.lon], { icon }); m = L.marker([p.lat, p.lon], { icon });
} else { } else {
m = L.circleMarker([p.lat, p.lon], { m = L.circleMarker([p.lat, p.lon], {
@ -3016,7 +3076,7 @@ function renderPoints(existing, points, colorFn, cluster, feed) {
if (m) { if (m) {
m.setLatLng([p.lat, p.lon]); m.setLatLng([p.lat, p.lon]);
m._osintP = p; m._osintP = p;
if (feed) m.setIcon(feedIcon(feed, col, p.heading)); if (feed) m.setIcon(feedIcon(feed, col, p.heading, feedPulse(feed, p)));
else if (m.setStyle) m.setStyle({ color: col, fillColor: col }); else if (m.setStyle) m.setStyle({ color: col, fillColor: col });
} else { } else {
const nm = makePointMarker(p, colorFn, feed, renderer); const nm = makePointMarker(p, colorFn, feed, renderer);
@ -3286,6 +3346,10 @@ async function toggleAircraft() {
if (acOn) await loadAircraft(); if (acOn) await loadAircraft();
else acGroup = dropLayer(acGroup); else acGroup = dropLayer(acGroup);
} }
function toggleAircraftMil() {
acMilOn = document.getElementById('lp-ac-mil-on').checked;
if (acOn) loadAircraft();
}
async function loadAircraft() { async function loadAircraft() {
if (!map) return; if (!map) return;
if (map.getZoom() <= 3) { if (map.getZoom() <= 3) {
@ -3297,8 +3361,17 @@ async function loadAircraft() {
const r = await overlayFetch(`${API}/api/aircraft?bbox=${currentBBox()}${dvrQs()}`); const r = await overlayFetch(`${API}/api/aircraft?bbox=${currentBBox()}${dvrQs()}`);
const pts = await r.json(); const pts = await r.json();
if (req !== overlayReq.ac) return; if (req !== overlayReq.ac) return;
acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], p => acColor(p), true, 'ac'); const all = Array.isArray(pts) ? pts : [];
document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString(); noteMilSupport(all);
const shown = acMilOn ? all.filter(acVisible) : all;
acGroup = renderPoints(acGroup, shown, p => acColor(p), true, 'ac');
document.getElementById('lp-ac-count').textContent = shown.length.toLocaleString();
const milEl = document.getElementById('lp-ac-mil-count');
if (milEl) {
milEl.textContent = acMilOn
? shown.length.toLocaleString()
: String(all.filter(q => ((q.extra || {}).role === 'military')).length);
}
addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL'); addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL');
addExtraAttrib('<a href="https://www.planespotters.net/photo/api">Photo © planespotters.net</a>'); addExtraAttrib('<a href="https://www.planespotters.net/photo/api">Photo © planespotters.net</a>');
} catch (e) { } catch (e) {

View file

@ -0,0 +1,57 @@
"""Aircraft popup enrichment + emergency/MIL layer contract (static HTML)."""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
HTML = (ROOT / "app/static/index.html").read_text()
def _fn(name: str, nxt: str) -> str:
return HTML.split(f"function {name}", 1)[1].split(f"function {nxt}", 1)[0]
def test_popup_has_required_adsb_fields_and_photo():
js = _fn("pointPopup", "loadPlanePhoto")
for field in ("callsign", "hex", "registration", "type", "alt", "gs", "squawk"):
assert f"add('{field}'" in js
assert "class=\"ps-photo\"" in js or "class='ps-photo'" in js
assert "wikipedia" not in js.lower()
assert "ceo" not in js.lower()
def test_emergency_badge_and_squawk_codes():
assert "role-badge emergency" in HTML
assert "hdg-emerg" in HTML
assert "EMERG_SQUAWK" in HTML
assert "['7700', '7600', '7500']" in HTML
emerg = HTML.split("function acIsEmergency", 1)[1].split("function acVisible", 1)[0]
assert "EMERG_SQUAWK.has(sq)" in emerg
color = HTML.split("function acColor", 1)[1].split("function connectLiveWs", 1)[0]
assert "acIsEmergency(p)" in color
assert "#ff5d5d" in color
def test_mil_toggle_hidden_until_role_flag_and_never_hits_adsb_lol():
assert 'id="lp-ac-mil-row"' in HTML
assert 'id="lp-ac-mil-on"' in HTML
row = HTML.split('id="lp-ac-mil-row"', 1)[1].split(">", 1)[0]
assert "hidden" in row
on = HTML.split('id="lp-ac-mil-on"', 1)[1].split(">", 1)[0]
assert "checked" not in on
load = HTML.split("async function loadAircraft", 1)[1].split("async function toggleTrains", 1)[0]
assert "/api/aircraft?bbox=" in load
assert "api.adsb.lol" not in load
assert "noteMilSupport" in load
assert "acMilOn" in load
note = HTML.split("function noteMilSupport", 1)[1].split("function acColor", 1)[0]
assert "extra.role" in note
assert "lp-ac-mil-row" in note
assert "hidden = false" in note
def test_planespotters_lazy_photo_still_wired():
assert "function loadPlanePhoto" in HTML
assert "/api/aircraft/photo?" in HTML
assert "map.on('popupopen', (e) => { loadPlanePhoto(e.popup); });" in HTML

132
tests/test_conflicts.py Normal file
View file

@ -0,0 +1,132 @@
"""GET /api/conflicts — curated conflict-zone catalog + event-count roll-up.
No outbound HTTP: event counts come from geocoded rows already (or not) in the
DB, and the API tests monkeypatch ``main._fetch_geocoded_points`` so no database
is required for the contract checks.
"""
from datetime import datetime, timezone
import httpx
from conflicts import SEVERITIES, conflict_zones, zone_event_stats
from live_layers import overlay_catalog
from main import app
BASE = "http://test"
def _get(path: str, monkeypatch=None, points=None) -> httpx.Response:
import asyncio
async def run() -> httpx.Response:
if monkeypatch is not None:
async def fake():
return points or []
monkeypatch.setattr("main._fetch_geocoded_points", fake)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url=BASE) as client:
return await client.get(path)
return asyncio.run(run())
# ── Catalog shape ──────────────────────────────────────────────────────
def test_catalog_length():
zones = conflict_zones()
assert len(zones) == 13
def test_catalog_severity_enum():
zones = conflict_zones()
sevs = {z["severity"] for z in zones}
assert sevs.issubset(SEVERITIES)
# All three tiers are represented.
assert sevs == SEVERITIES
def test_catalog_fields_factual_and_complete():
zones = conflict_zones()
ids = [z["id"] for z in zones]
assert len(set(ids)) == len(ids) # unique ids
for z in zones:
assert z["label"]
assert z["description"].strip()
assert -90.0 <= z["lat"] <= 90.0
assert -180.0 <= z["lon"] <= 180.0
# internal-only bbox is well-formed: (min_lat, min_lon, max_lat, max_lon)
min_lat, min_lon, max_lat, max_lon = z["bbox"]
assert min_lat <= max_lat and min_lon <= max_lon
assert min_lat <= z["lat"] <= max_lat and min_lon <= z["lon"] <= max_lon
def test_overlay_catalog_has_conflicts():
entry = overlay_catalog()["conflicts"]
assert entry["kind"] == "points"
assert entry["endpoint"] == "/api/conflicts"
# ── Pure counting ──────────────────────────────────────────────────────
TS1 = datetime(2026, 8, 30, 12, 0, tzinfo=timezone.utc)
TS2 = datetime(2026, 8, 30, 13, 0, tzinfo=timezone.utc)
def test_zone_event_stats_counts_and_picks_latest():
bbox = (40.0, 20.0, 52.0, 40.0) # roughly Ukraine
points = [
(50.45, 30.52, TS1), # inside
(48.0, 25.0, TS2), # inside, later
(0.0, -60.0, TS1), # outside
(15.0, 45.0, TS2), # outside (lat ok, lon out)
]
count, latest = zone_event_stats(points, bbox)
assert count == 2
assert latest == TS2
def test_zone_event_stats_empty_bbox():
count, latest = zone_event_stats([], (0.0, 0.0, 1.0, 1.0))
assert count == 0
assert latest is None
# ── API contract (mocked map items, no DB) ─────────────────────────────
def test_conflicts_returns_catalog_with_mocked_counts(monkeypatch):
points = [
(50.45, 30.52, TS1), # Ukraine
(25.03, 121.56, TS2), # Taiwan Strait
(0.0, -60.0, TS1), # nowhere
]
resp = _get("/api/conflicts", monkeypatch=monkeypatch, points=points)
assert resp.status_code == 200
body = resp.json()
assert "zones" in body and "timestamp" in body
by_id = {z["id"]: z for z in body["zones"]}
assert len(body["zones"]) == 13
zone = by_id["ukraine"]
assert zone["eventCount"] == 1
assert zone["lastUpdated"] == TS1.isoformat().replace("+00:00", "Z")
assert zone["severity"] == "war"
assert by_id["taiwan_strait"]["eventCount"] == 1
assert by_id["gaza"]["eventCount"] == 0
# exact per-zone key contract the frontend consumes
assert set(zone.keys()) == {
"id", "label", "severity", "lat", "lon",
"description", "eventCount", "lastUpdated",
}
def test_conflicts_empty_db_yields_zero_counts(monkeypatch):
resp = _get("/api/conflicts", monkeypatch=monkeypatch, points=[])
assert resp.status_code == 200
body = resp.json()
assert all(z["eventCount"] == 0 for z in body["zones"])
assert all(z["lastUpdated"] is None for z in body["zones"])

View file

@ -1,5 +1,7 @@
"""Unit tests for live map-layer mappers (aircraft, trains, AIS, WFIGS, Caltrans).""" """Unit tests for live map-layer mappers (aircraft, trains, AIS, WFIGS, Caltrans)."""
import json
from live_layers import ( from live_layers import (
MARKER_FIELDS, MARKER_FIELDS,
bbox_center_radius_nm, bbox_center_radius_nm,
@ -25,7 +27,7 @@ from live_layers import (
_wfigs_params, _wfigs_params,
) )
from camera_scraper import parse_caltrans_json from camera_scraper import parse_caltrans_json, parse_mdot_json
def test_parse_bbox_and_radius_clamps_to_150_nm(): def test_parse_bbox_and_radius_clamps_to_150_nm():
@ -257,6 +259,65 @@ def test_parse_caltrans_skips_oos_and_maps_jpeg_hls():
assert "rtsp://" not in cam["snapshot_url"].lower() assert "rtsp://" not in cam["snapshot_url"].lower()
def test_parse_mdot_extracts_html_fields_and_bbox_filters():
rows = [
# In-bbox, full fields.
{
"route": "11 Mile",
"county": 'Wayne County <a href="/MiDrive/map?cameras=true&lat=42.491304&lon=-83.04479&zoom=15&id=1129"target="_blank">Go to</a>',
"location": " @ Mound NB",
"direction": "Traffic closest to camera is traveling north.",
"image": '<img alt="x" class="cameraImageForActivePane" id="1129Img" src="https://micamerasimages.net/thumbs/semtoc_cam_253.flv.jpg?item=1" height="170" width="250" onerror="cameraImageBroken(this)">',
},
# Out of bbox (lat 50) → drop.
{
"route": "Far",
"county": 'Nowhere <a href="/MiDrive/map?lat=50.0&lon=-83.0&zoom=15&id=9999">Go to</a>',
"location": "",
"image": '<img src="https://micamerasimages.net/thumbs/x.jpg">',
},
# Missing coordinates → drop.
{
"route": "NoCoords",
"county": 'Somewhere <a href="/MiDrive/map?zoom=15&id=8888">Go to</a>',
"location": "",
"image": '<img src="https://micamerasimages.net/thumbs/y.jpg">',
},
# Missing image → drop.
{
"route": "NoImage",
"county": 'Kent <a href="/MiDrive/map?lat=42.8841&lon=-85.6646&zoom=15&id=2113">Go to</a>',
"location": " @ Division",
"image": "",
},
# RTSP image src → drop.
{
"route": "Rtsp",
"county": 'Wayne <a href="/MiDrive/map?lat=42.4&lon=-83.1&zoom=15&id=1234">Go to</a>',
"location": "",
"image": '<img src="rtsp://10.0.0.1/stream">',
},
]
cams = parse_mdot_json(json.dumps(rows), "mdotjboss.state.mi.us")
assert len(cams) == 1
cam = cams[0]
assert cam["discovery_source"] == "mdot"
assert cam["location_lat"] == 42.491304
assert cam["location_lon"] == -83.04479
assert cam["snapshot_url"] == "https://micamerasimages.net/thumbs/semtoc_cam_253.flv.jpg?item=1"
assert cam["source_url"] == "https://mdotjboss.state.mi.us/MiDrive/camera/1129"
assert cam["device_type"] == "http"
assert cam["vendor"] == "MDOT"
assert "11 Mile @ Mound NB" in cam["location_name"]
assert "Wayne County" in cam["location_name"]
def test_parse_mdot_handles_malformed_payload():
assert parse_mdot_json("not json", "mdot") == []
assert parse_mdot_json('{"not": "a list"}', "mdot") == []
assert parse_mdot_json("[]", "mdot") == []
def test_quantize_bbox_stable_under_jitter(): def test_quantize_bbox_stable_under_jitter():
a = quantize_bbox(*parse_bbox("-78.7912,35.7711,-78.6101,35.9102")) a = quantize_bbox(*parse_bbox("-78.7912,35.7711,-78.6101,35.9102"))
b = quantize_bbox(*parse_bbox("-78.7900,35.7700,-78.6110,35.9090")) b = quantize_bbox(*parse_bbox("-78.7900,35.7700,-78.6110,35.9090"))