Compare commits
2 Commits
b86aba7bcc
...
9171a5134d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9171a5134d | ||
|
|
0b394c9bae |
55
collector/maidenhead.py
Normal file
55
collector/maidenhead.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Maidenhead locator helpers.
|
||||
|
||||
latlon_to_subsquare(lat, lon) -> str e.g. "JO78ai"
|
||||
subsquare_bounds(sq) -> dict {lat_min, lon_min, lat_max, lon_max}
|
||||
"""
|
||||
|
||||
|
||||
def latlon_to_subsquare(lat: float, lon: float) -> str:
|
||||
"""Convert WGS84 coordinates to 6-character Maidenhead subsquare."""
|
||||
lon_n = lon + 180.0
|
||||
lat_n = lat + 90.0
|
||||
|
||||
field_lon = int(lon_n / 20)
|
||||
field_lat = int(lat_n / 10)
|
||||
lon_n = lon_n % 20
|
||||
lat_n = lat_n % 10
|
||||
|
||||
sq_lon = int(lon_n / 2)
|
||||
sq_lat = int(lat_n / 1)
|
||||
lon_n = (lon_n % 2) * 12
|
||||
lat_n = (lat_n % 1) * 24
|
||||
|
||||
sub_lon = int(lon_n)
|
||||
sub_lat = int(lat_n)
|
||||
|
||||
return (
|
||||
chr(ord("A") + field_lon)
|
||||
+ chr(ord("A") + field_lat)
|
||||
+ str(sq_lon)
|
||||
+ str(sq_lat)
|
||||
+ chr(ord("a") + sub_lon)
|
||||
+ chr(ord("a") + sub_lat)
|
||||
)
|
||||
|
||||
|
||||
def subsquare_bounds(sq: str) -> dict:
|
||||
"""
|
||||
Return bounding box for a 6-character Maidenhead subsquare.
|
||||
Each subsquare is 5' longitude ?? 2.5' latitude (~10 ?? 4.6 km in Sweden).
|
||||
"""
|
||||
lon = (ord(sq[0]) - ord("A")) * 20.0
|
||||
lat = (ord(sq[1]) - ord("A")) * 10.0
|
||||
lon += int(sq[2]) * 2.0
|
||||
lat += int(sq[3]) * 1.0
|
||||
lon += (ord(sq[4]) - ord("a")) * (1.0 / 12.0)
|
||||
lat += (ord(sq[5]) - ord("a")) * (1.0 / 24.0)
|
||||
lon -= 180.0
|
||||
lat -= 90.0
|
||||
return {
|
||||
"lat_min": lat,
|
||||
"lon_min": lon,
|
||||
"lat_max": lat + (1.0 / 24.0),
|
||||
"lon_max": lon + (1.0 / 12.0),
|
||||
}
|
||||
@@ -17,12 +17,30 @@ from typing import Annotated, Optional
|
||||
import aprslib
|
||||
import uvicorn
|
||||
from fastapi import Depends, FastAPI, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel
|
||||
|
||||
from aprs_is import run_aprs_is_collector
|
||||
from config import Config
|
||||
from db import init_db, insert_is_frame, insert_rf_frame
|
||||
from maidenhead import latlon_to_subsquare, subsquare_bounds
|
||||
|
||||
import re as _re
|
||||
|
||||
def _extract_digis(path: str) -> str:
|
||||
"""
|
||||
Reduce a raw APRS path to the actual physical digipeaters that handled it.
|
||||
WIDE*, RELAY, TRACE etc are aliases and are stripped.
|
||||
Returns 'DIRECT' if no real digi hops remain.
|
||||
"""
|
||||
if not path or path == 'Direkt':
|
||||
return 'DIRECT'
|
||||
_ALIAS = _re.compile(r'^(WIDE|RELAY|TRACE|ECHO|GATE|TCPIP|TCPXX|NOGATE|RFONLY|qA[A-Z])', _re.IGNORECASE)
|
||||
hops = [h.strip() for h in path.split(',')]
|
||||
digis = [h for h in hops if h and not _ALIAS.match(h)]
|
||||
return ','.join(digis) if digis else 'DIRECT'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,6 +119,7 @@ async def _run_aprs_is() -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(title="aprs-collector", lifespan=lifespan)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
_bearer = HTTPBearer()
|
||||
|
||||
|
||||
@@ -192,3 +211,255 @@ async def ingest_rf(frame: RFFrameIn) -> None:
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("ingest_rf error (frame dropped): %s", exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coverage endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/coverage/squares")
|
||||
async def coverage_squares():
|
||||
"""
|
||||
Return all Maidenhead subsquares with path breakdown per square.
|
||||
"""
|
||||
async with _pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT lat, lon, path, COUNT(*) AS hits
|
||||
FROM rf_frames
|
||||
WHERE lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
GROUP BY lat, lon, path
|
||||
"""
|
||||
)
|
||||
|
||||
# Second query: first_seen per lat/lon
|
||||
async with _pool.acquire() as conn:
|
||||
firstseen_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT lat, lon, MIN(ts) AS first_seen
|
||||
FROM rf_frames
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
GROUP BY lat, lon
|
||||
"""
|
||||
)
|
||||
|
||||
# Build first_seen per square
|
||||
sq_first_seen: dict[str, str] = {}
|
||||
for row in firstseen_rows:
|
||||
sq = latlon_to_subsquare(row['lat'], row['lon'])
|
||||
ts = row['first_seen'].isoformat()
|
||||
if sq not in sq_first_seen or ts < sq_first_seen[sq]:
|
||||
sq_first_seen[sq] = ts
|
||||
|
||||
# Third query: stations per square
|
||||
async with _pool.acquire() as conn:
|
||||
station_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT lat, lon, src_call, COUNT(*) AS hits
|
||||
FROM rf_frames
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
GROUP BY lat, lon, src_call
|
||||
"""
|
||||
)
|
||||
|
||||
squares: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
sq = latlon_to_subsquare(row["lat"], row["lon"])
|
||||
if sq not in squares:
|
||||
squares[sq] = {"paths": {}, "stations": {}}
|
||||
p = _extract_digis(row["path"] or "")
|
||||
squares[sq]["paths"][p] = squares[sq]["paths"].get(p, 0) + row["hits"]
|
||||
|
||||
for row in station_rows:
|
||||
sq = latlon_to_subsquare(row["lat"], row["lon"])
|
||||
if sq not in squares:
|
||||
squares[sq] = {"paths": {}, "stations": {}}
|
||||
call = row["src_call"]
|
||||
squares[sq]["stations"][call] = squares[sq]["stations"].get(call, 0) + row["hits"]
|
||||
|
||||
result = []
|
||||
for sq, data in squares.items():
|
||||
bounds = subsquare_bounds(sq)
|
||||
total = sum(data["paths"].values())
|
||||
sorted_paths = sorted(data["paths"].items(), key=lambda x: x[1], reverse=True)
|
||||
sorted_stations = sorted(data["stations"].items(), key=lambda x: x[1], reverse=True)
|
||||
import math
|
||||
sq_lat = (bounds['lat_min'] + bounds['lat_max']) / 2
|
||||
sq_lon = (bounds['lon_min'] + bounds['lon_max']) / 2
|
||||
dlat = math.radians(sq_lat - 58.35)
|
||||
dlon = math.radians(sq_lon - 14.05)
|
||||
a = math.sin(dlat/2)**2 + math.cos(math.radians(58.35)) * math.cos(math.radians(sq_lat)) * math.sin(dlon/2)**2
|
||||
dist_km = round(6371 * 2 * math.asin(math.sqrt(a)), 1)
|
||||
|
||||
result.append({
|
||||
"square": sq,
|
||||
"hits": total,
|
||||
"first_seen": sq_first_seen.get(sq),
|
||||
"dist_km": dist_km,
|
||||
"paths": [{"path": p, "count": c} for p, c in sorted_paths],
|
||||
"stations": [{"call": s, "count": c} for s, c in sorted_stations],
|
||||
**bounds,
|
||||
})
|
||||
|
||||
return {"squares": result}
|
||||
|
||||
|
||||
@app.get("/coverage/points")
|
||||
async def coverage_points():
|
||||
"""
|
||||
Return individual heard-direct observations with position.
|
||||
Used for the zoomed-in map layer.
|
||||
"""
|
||||
async with _pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (src_call, ROUND(lat::numeric,4), ROUND(lon::numeric,4))
|
||||
src_call,
|
||||
ROUND(lat::numeric, 4) AS lat,
|
||||
ROUND(lon::numeric, 4) AS lon,
|
||||
ts
|
||||
FROM rf_frames
|
||||
WHERE heard_direct = TRUE
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
ORDER BY src_call, ROUND(lat::numeric,4), ROUND(lon::numeric,4), ts DESC
|
||||
"""
|
||||
)
|
||||
|
||||
return {
|
||||
"points": [
|
||||
{
|
||||
"call": row["src_call"],
|
||||
"lat": float(row["lat"]),
|
||||
"lon": float(row["lon"]),
|
||||
"ts": row["ts"].isoformat(),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.get("/coverage/station/{callsign}")
|
||||
async def coverage_station(callsign: str):
|
||||
"""Detailed statistics for a single station."""
|
||||
import math
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
R = 6371
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
|
||||
return R * 2 * math.asin(math.sqrt(a))
|
||||
|
||||
rx_lat = float(_config.station_call and 58.35)
|
||||
rx_lon = 14.05
|
||||
|
||||
async with _pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT
|
||||
COUNT(*) AS total_frames,
|
||||
MIN(ts) AS first_heard,
|
||||
MAX(ts) AS last_heard,
|
||||
COUNT(DISTINCT ROUND(lat::numeric,3) || ',' || ROUND(lon::numeric,3))
|
||||
AS unique_positions,
|
||||
COUNT(DISTINCT path) AS unique_paths,
|
||||
AVG(lat) AS avg_lat,
|
||||
AVG(lon) AS avg_lon
|
||||
FROM rf_frames
|
||||
WHERE src_call = $1
|
||||
AND lat IS NOT NULL
|
||||
""", callsign)
|
||||
|
||||
path_rows = await conn.fetch("""
|
||||
SELECT path, COUNT(*) AS cnt
|
||||
FROM rf_frames
|
||||
WHERE src_call = $1 AND lat IS NOT NULL
|
||||
GROUP BY path
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 5
|
||||
""", callsign)
|
||||
|
||||
square_rows = await conn.fetch("""
|
||||
SELECT lat, lon, COUNT(*) AS cnt
|
||||
FROM rf_frames
|
||||
WHERE src_call = $1 AND lat IS NOT NULL
|
||||
GROUP BY lat, lon
|
||||
ORDER BY cnt DESC
|
||||
""", callsign)
|
||||
|
||||
if not row or row["total_frames"] == 0:
|
||||
raise HTTPException(status_code=404, detail="Station not found")
|
||||
|
||||
# Distance from each unique position to RX
|
||||
distances = []
|
||||
squares_seen = set()
|
||||
for r in square_rows:
|
||||
d = haversine(rx_lat, rx_lon, r["lat"], r["lon"])
|
||||
distances.append(d)
|
||||
squares_seen.add(latlon_to_subsquare(r["lat"], r["lon"]))
|
||||
|
||||
return {
|
||||
"callsign": callsign,
|
||||
"total_frames": row["total_frames"],
|
||||
"first_heard": row["first_heard"].isoformat(),
|
||||
"last_heard": row["last_heard"].isoformat(),
|
||||
"unique_positions": row["unique_positions"],
|
||||
"unique_paths": row["unique_paths"],
|
||||
"squares": sorted(squares_seen),
|
||||
"distance_min_km": round(min(distances), 1) if distances else None,
|
||||
"distance_max_km": round(max(distances), 1) if distances else None,
|
||||
"distance_avg_km": round(sum(distances)/len(distances), 1) if distances else None,
|
||||
"top_paths": [{"path": _extract_digis(r["path"] or ""), "count": r["cnt"]} for r in path_rows],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/coverage/digis")
|
||||
async def coverage_digis():
|
||||
"""
|
||||
Return latest known position for every callsign we have heard.
|
||||
Used client-side to resolve digi positions for path drawing.
|
||||
"""
|
||||
async with _pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (src_call)
|
||||
src_call, lat, lon, ts
|
||||
FROM rf_frames
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
ORDER BY src_call, ts DESC
|
||||
"""
|
||||
)
|
||||
return {
|
||||
"digis": {
|
||||
row["src_call"]: {
|
||||
"lat": row["lat"],
|
||||
"lon": row["lon"],
|
||||
"ts": row["ts"].isoformat(),
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.get("/coverage/rx-stats")
|
||||
async def coverage_rx_stats():
|
||||
"""
|
||||
Aggregate all reduced paths heard by the RX station, sorted by count.
|
||||
"""
|
||||
async with _pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT path, COUNT(*) AS hits
|
||||
FROM rf_frames
|
||||
GROUP BY path
|
||||
"""
|
||||
)
|
||||
|
||||
paths: dict[str, int] = {}
|
||||
for row in rows:
|
||||
p = _extract_digis(row["path"] or "")
|
||||
paths[p] = paths.get(p, 0) + row["hits"]
|
||||
|
||||
sorted_paths = sorted(paths.items(), key=lambda x: x[1], reverse=True)
|
||||
return {"paths": [{"path": p, "count": c} for p, c in sorted_paths]}
|
||||
|
||||
442
collector/main.py~
Normal file
442
collector/main.py~
Normal file
@@ -0,0 +1,442 @@
|
||||
"""
|
||||
APRS collector ??? DMZ service.
|
||||
|
||||
POST /ingest/rf ??? agw-forwarder on shack machine pushes RF frames here
|
||||
GET /health ??? Docker / uptime check
|
||||
|
||||
APRS-IS collector runs as a background asyncio task alongside uvicorn.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import aprslib
|
||||
import uvicorn
|
||||
from fastapi import Depends, FastAPI, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel
|
||||
|
||||
from aprs_is import run_aprs_is_collector
|
||||
from config import Config
|
||||
from db import init_db, insert_is_frame, insert_rf_frame
|
||||
from maidenhead import latlon_to_subsquare, subsquare_bounds
|
||||
|
||||
import re as _re
|
||||
|
||||
def _extract_digis(path: str) -> str:
|
||||
"""
|
||||
Reduce a raw APRS path to the actual physical digipeaters that handled it.
|
||||
WIDE*, RELAY, TRACE etc are aliases and are stripped.
|
||||
Returns 'DIRECT' if no real digi hops remain.
|
||||
"""
|
||||
if not path or path == 'Direkt':
|
||||
return 'DIRECT'
|
||||
_ALIAS = _re.compile(r'^(WIDE|RELAY|TRACE|ECHO|GATE|TCPIP|TCPXX|NOGATE|RFONLY|qA[A-Z])', _re.IGNORECASE)
|
||||
hops = [h.strip() for h in path.split(',')]
|
||||
digis = [h for h in hops if h and not _ALIAS.match(h)]
|
||||
return ','.join(digis) if digis else 'DIRECT'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App state ??? populated during startup
|
||||
# ---------------------------------------------------------------------------
|
||||
_pool = None
|
||||
_config: Optional[Config] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup / shutdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global _pool, _config
|
||||
|
||||
_config = Config.from_env()
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, _config.log_level.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
|
||||
# Wait for Postgres
|
||||
for attempt in range(1, 61):
|
||||
try:
|
||||
_pool = await init_db(_config.db_url)
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.warning("DB not ready (%d/60): %s", attempt, exc)
|
||||
await asyncio.sleep(5)
|
||||
else:
|
||||
logger.error("Could not connect to database ??? giving up")
|
||||
sys.exit(1)
|
||||
|
||||
# Start APRS-IS collector as background task
|
||||
is_task = asyncio.create_task(_run_aprs_is())
|
||||
logger.info("Startup complete ??? listening for RF frames")
|
||||
|
||||
yield # ??? app runs here
|
||||
|
||||
is_task.cancel()
|
||||
try:
|
||||
await is_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
async def _run_aprs_is() -> None:
|
||||
async def on_is_frame(*, ts: datetime, raw: str) -> None:
|
||||
try:
|
||||
src_call = raw.split(">")[0] if ">" in raw else ""
|
||||
lat, lon, comment = _parse_position(raw)
|
||||
path = _extract_path(raw)
|
||||
await insert_is_frame(_pool, ts, src_call, lat, lon, path, comment, raw)
|
||||
logger.debug("IS %-9s lat=%s lon=%s", src_call,
|
||||
f"{lat:.4f}" if lat else "-",
|
||||
f"{lon:.4f}" if lon else "-")
|
||||
except Exception as exc:
|
||||
logger.warning("IS frame error: %s raw=%r", exc, raw[:80])
|
||||
|
||||
await run_aprs_is_collector(
|
||||
host=_config.aprs_is_host,
|
||||
port=_config.aprs_is_port,
|
||||
callsign=_config.aprs_is_callsign,
|
||||
passcode=_config.aprs_is_passcode,
|
||||
filter_str=_config.aprs_is_filter,
|
||||
on_frame=on_is_frame,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI app
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(title="aprs-collector", lifespan=lifespan)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
_bearer = HTTPBearer()
|
||||
|
||||
|
||||
def _require_api_key(
|
||||
creds: Annotated[HTTPAuthorizationCredentials, Depends(_bearer)],
|
||||
) -> None:
|
||||
if creds.credentials != _config.api_key:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RFFrameIn(BaseModel):
|
||||
ts: datetime # UTC timestamp from forwarder
|
||||
src_call: str
|
||||
dst_call: str
|
||||
via_path: str # comma-separated, may contain '*'
|
||||
info: str # APRS info field
|
||||
heard_direct: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_position(packet: str) -> tuple[Optional[float], Optional[float], Optional[str]]:
|
||||
try:
|
||||
p = aprslib.parse(packet)
|
||||
return p.get("latitude"), p.get("longitude"), p.get("comment")
|
||||
except Exception:
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _extract_path(raw: str) -> str:
|
||||
try:
|
||||
header = raw.split(":")[0]
|
||||
parts = header.split(",")
|
||||
return ",".join(parts[1:]) if len(parts) > 1 else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/ingest/rf", status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(_require_api_key)])
|
||||
async def ingest_rf(frame: RFFrameIn) -> None:
|
||||
"""Receive a single APRS RF frame from the shack forwarder."""
|
||||
try:
|
||||
# Build TNC2 string so aprslib can parse position
|
||||
tnc2 = f"{frame.src_call}>{frame.dst_call}"
|
||||
if frame.via_path:
|
||||
tnc2 += f",{frame.via_path}"
|
||||
tnc2 += f":{frame.info}"
|
||||
|
||||
lat, lon, _ = _parse_position(tnc2)
|
||||
|
||||
logger.info(
|
||||
"RF %-6s %-9s [%s] lat=%s lon=%s",
|
||||
"DIRECT" if frame.heard_direct else "VIA",
|
||||
frame.src_call,
|
||||
frame.via_path,
|
||||
f"{lat:.4f}" if lat else "-",
|
||||
f"{lon:.4f}" if lon else "-",
|
||||
)
|
||||
|
||||
await insert_rf_frame(
|
||||
pool=_pool,
|
||||
ts=frame.ts,
|
||||
rx_station=_config.station_call,
|
||||
src_call=frame.src_call,
|
||||
dst_call=frame.dst_call,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
heard_direct=frame.heard_direct,
|
||||
path=frame.via_path,
|
||||
info=frame.info,
|
||||
raw=tnc2,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("ingest_rf error (frame dropped): %s", exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coverage endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/coverage/squares")
|
||||
async def coverage_squares():
|
||||
"""
|
||||
Return all Maidenhead subsquares with path breakdown per square.
|
||||
"""
|
||||
async with _pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT lat, lon, path, COUNT(*) AS hits
|
||||
FROM rf_frames
|
||||
WHERE lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
GROUP BY lat, lon, path
|
||||
"""
|
||||
)
|
||||
|
||||
# Second query: first_seen per lat/lon
|
||||
async with _pool.acquire() as conn:
|
||||
firstseen_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT lat, lon, MIN(ts) AS first_seen
|
||||
FROM rf_frames
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
GROUP BY lat, lon
|
||||
"""
|
||||
)
|
||||
|
||||
# Build first_seen per square
|
||||
sq_first_seen: dict[str, str] = {}
|
||||
for row in firstseen_rows:
|
||||
sq = latlon_to_subsquare(row['lat'], row['lon'])
|
||||
ts = row['first_seen'].isoformat()
|
||||
if sq not in sq_first_seen or ts < sq_first_seen[sq]:
|
||||
sq_first_seen[sq] = ts
|
||||
|
||||
# Third query: stations per square
|
||||
async with _pool.acquire() as conn:
|
||||
station_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT lat, lon, src_call, COUNT(*) AS hits
|
||||
FROM rf_frames
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
GROUP BY lat, lon, src_call
|
||||
"""
|
||||
)
|
||||
|
||||
squares: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
sq = latlon_to_subsquare(row["lat"], row["lon"])
|
||||
if sq not in squares:
|
||||
squares[sq] = {"paths": {}, "stations": {}}
|
||||
p = _extract_digis(row["path"] or "")
|
||||
squares[sq]["paths"][p] = squares[sq]["paths"].get(p, 0) + row["hits"]
|
||||
|
||||
for row in station_rows:
|
||||
sq = latlon_to_subsquare(row["lat"], row["lon"])
|
||||
if sq not in squares:
|
||||
squares[sq] = {"paths": {}, "stations": {}}
|
||||
call = row["src_call"]
|
||||
squares[sq]["stations"][call] = squares[sq]["stations"].get(call, 0) + row["hits"]
|
||||
|
||||
result = []
|
||||
for sq, data in squares.items():
|
||||
bounds = subsquare_bounds(sq)
|
||||
total = sum(data["paths"].values())
|
||||
sorted_paths = sorted(data["paths"].items(), key=lambda x: x[1], reverse=True)
|
||||
sorted_stations = sorted(data["stations"].items(), key=lambda x: x[1], reverse=True)
|
||||
import math
|
||||
sq_lat = (bounds['lat_min'] + bounds['lat_max']) / 2
|
||||
sq_lon = (bounds['lon_min'] + bounds['lon_max']) / 2
|
||||
dlat = math.radians(sq_lat - 58.35)
|
||||
dlon = math.radians(sq_lon - 14.05)
|
||||
a = math.sin(dlat/2)**2 + math.cos(math.radians(58.35)) * math.cos(math.radians(sq_lat)) * math.sin(dlon/2)**2
|
||||
dist_km = round(6371 * 2 * math.asin(math.sqrt(a)), 1)
|
||||
|
||||
result.append({
|
||||
"square": sq,
|
||||
"hits": total,
|
||||
"first_seen": sq_first_seen.get(sq),
|
||||
"dist_km": dist_km,
|
||||
"paths": [{"path": p, "count": c} for p, c in sorted_paths],
|
||||
"stations": [{"call": s, "count": c} for s, c in sorted_stations],
|
||||
**bounds,
|
||||
})
|
||||
|
||||
return {"squares": result}
|
||||
|
||||
|
||||
@app.get("/coverage/points")
|
||||
async def coverage_points():
|
||||
"""
|
||||
Return individual heard-direct observations with position.
|
||||
Used for the zoomed-in map layer.
|
||||
"""
|
||||
async with _pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (src_call, ROUND(lat::numeric,4), ROUND(lon::numeric,4))
|
||||
src_call,
|
||||
ROUND(lat::numeric, 4) AS lat,
|
||||
ROUND(lon::numeric, 4) AS lon,
|
||||
ts
|
||||
FROM rf_frames
|
||||
WHERE heard_direct = TRUE
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
ORDER BY src_call, ROUND(lat::numeric,4), ROUND(lon::numeric,4), ts DESC
|
||||
"""
|
||||
)
|
||||
|
||||
return {
|
||||
"points": [
|
||||
{
|
||||
"call": row["src_call"],
|
||||
"lat": float(row["lat"]),
|
||||
"lon": float(row["lon"]),
|
||||
"ts": row["ts"].isoformat(),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.get("/coverage/station/{callsign}")
|
||||
async def coverage_station(callsign: str):
|
||||
"""Detailed statistics for a single station."""
|
||||
import math
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
R = 6371
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
|
||||
return R * 2 * math.asin(math.sqrt(a))
|
||||
|
||||
rx_lat = float(_config.station_call and 58.35)
|
||||
rx_lon = 14.05
|
||||
|
||||
async with _pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT
|
||||
COUNT(*) AS total_frames,
|
||||
MIN(ts) AS first_heard,
|
||||
MAX(ts) AS last_heard,
|
||||
COUNT(DISTINCT ROUND(lat::numeric,3) || ',' || ROUND(lon::numeric,3))
|
||||
AS unique_positions,
|
||||
COUNT(DISTINCT path) AS unique_paths,
|
||||
AVG(lat) AS avg_lat,
|
||||
AVG(lon) AS avg_lon
|
||||
FROM rf_frames
|
||||
WHERE src_call = $1
|
||||
AND lat IS NOT NULL
|
||||
""", callsign)
|
||||
|
||||
path_rows = await conn.fetch("""
|
||||
SELECT path, COUNT(*) AS cnt
|
||||
FROM rf_frames
|
||||
WHERE src_call = $1 AND lat IS NOT NULL
|
||||
GROUP BY path
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 5
|
||||
""", callsign)
|
||||
|
||||
square_rows = await conn.fetch("""
|
||||
SELECT lat, lon, COUNT(*) AS cnt
|
||||
FROM rf_frames
|
||||
WHERE src_call = $1 AND lat IS NOT NULL
|
||||
GROUP BY lat, lon
|
||||
ORDER BY cnt DESC
|
||||
""", callsign)
|
||||
|
||||
if not row or row["total_frames"] == 0:
|
||||
raise HTTPException(status_code=404, detail="Station not found")
|
||||
|
||||
# Distance from each unique position to RX
|
||||
distances = []
|
||||
squares_seen = set()
|
||||
for r in square_rows:
|
||||
d = haversine(rx_lat, rx_lon, r["lat"], r["lon"])
|
||||
distances.append(d)
|
||||
squares_seen.add(latlon_to_subsquare(r["lat"], r["lon"]))
|
||||
|
||||
return {
|
||||
"callsign": callsign,
|
||||
"total_frames": row["total_frames"],
|
||||
"first_heard": row["first_heard"].isoformat(),
|
||||
"last_heard": row["last_heard"].isoformat(),
|
||||
"unique_positions": row["unique_positions"],
|
||||
"unique_paths": row["unique_paths"],
|
||||
"squares": sorted(squares_seen),
|
||||
"distance_min_km": round(min(distances), 1) if distances else None,
|
||||
"distance_max_km": round(max(distances), 1) if distances else None,
|
||||
"distance_avg_km": round(sum(distances)/len(distances), 1) if distances else None,
|
||||
"top_paths": [{"path": _extract_digis(r["path"] or ""), "count": r["cnt"]} for r in path_rows],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/coverage/digis")
|
||||
async def coverage_digis():
|
||||
"""
|
||||
Return latest known position for every callsign we have heard.
|
||||
Used client-side to resolve digi positions for path drawing.
|
||||
"""
|
||||
async with _pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (src_call)
|
||||
src_call, lat, lon, ts
|
||||
FROM rf_frames
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
ORDER BY src_call, ts DESC
|
||||
"""
|
||||
)
|
||||
return {
|
||||
"digis": {
|
||||
row["src_call"]: {
|
||||
"lat": row["lat"],
|
||||
"lon": row["lon"],
|
||||
"ts": row["ts"].isoformat(),
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
}
|
||||
@@ -30,3 +30,13 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8084:80"
|
||||
volumes:
|
||||
- ./web/index.html:/usr/share/nginx/html/index.html:ro
|
||||
- ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
624
web/index.html
Normal file
624
web/index.html
Normal file
@@ -0,0 +1,624 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="sv">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>heardlog ??? APRS RF Coverage</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Courier New', monospace; background: #0d0900; color: #ffb347; height: 100vh; height: 100dvh; display: flex; flex-direction: column; }
|
||||
header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #0d0900; border-bottom: 1px solid #3a2200; flex-shrink: 0; gap: 8px; flex-wrap: wrap; }
|
||||
.logo { font-size: 0.95rem; font-weight: bold; letter-spacing: 0.1em; color: #ff8c00; white-space: nowrap; }
|
||||
.logo span { color: #ffb347; font-weight: normal; }
|
||||
.logo em { color: #ffd580; font-style: normal; font-weight: normal; font-size: 0.85em; }
|
||||
.controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.stat { font-size: 0.70rem; color: #7a5500; letter-spacing: 0.06em; white-space: nowrap; }
|
||||
.stat strong { color: #ffb347; }
|
||||
.btn { font-family: inherit; font-size: 0.70rem; letter-spacing: 0.08em; background: transparent; border: 1px solid #5a3300; color: #ff8c00; padding: 6px 14px; cursor: pointer; transition: all 0.15s; white-space: nowrap; touch-action: manipulation; }
|
||||
.btn:hover { background: #2a1500; border-color: #ff8c00; }
|
||||
#map { flex: 1; min-height: 0; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.logo em { display: none; }
|
||||
.stat { font-size: 0.65rem; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); opacity: 0.9; }
|
||||
70% { transform: scale(2.2); opacity: 0; }
|
||||
100% { transform: scale(1); opacity: 0; }
|
||||
}
|
||||
.rx-marker { width: 14px; height: 14px; background: #ffe066; border: 2px solid #fff; border-radius: 50%; position: relative; }
|
||||
.rx-marker::after { content: ''; position: absolute; top: -2px; left: -2px; width: 14px; height: 14px; border: 2px solid #ffe066; border-radius: 50%; animation: pulse 2s ease-out infinite; }
|
||||
|
||||
/* Popup base */
|
||||
.sq-popup { font-family: 'Courier New', monospace; background: rgba(13,9,0,0.97); border: 1px solid #5a3300; color: #ffb347; width: min(300px, 90vw); }
|
||||
.sq-popup-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; border-bottom: 1px solid #3a2200; }
|
||||
.sq-popup-title { font-size: 0.85rem; color: #ff8c00; font-weight: bold; letter-spacing: 0.1em; }
|
||||
.sq-popup-sub { font-size: 0.70rem; color: #7a5500; }
|
||||
|
||||
/* Tabs */
|
||||
.sq-tabs { display: flex; border-bottom: 1px solid #3a2200; }
|
||||
.sq-tab { flex: 1; text-align: center; padding: 8px 0; font-size: 0.70rem; letter-spacing: 0.08em; cursor: pointer; color: #7a5500; border-bottom: 2px solid transparent; transition: all 0.1s; touch-action: manipulation; }
|
||||
.sq-tab.active { color: #ff8c00; border-bottom-color: #ff8c00; }
|
||||
.sq-panel { display: none; padding: 6px 10px; max-height: min(220px, 40vh); overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
||||
.sq-panel.active { display: block; }
|
||||
.sq-panel table { width: 100%; border-collapse: collapse; font-size: 0.70rem; }
|
||||
.sq-panel td { padding: 4px 0; }
|
||||
.sq-panel td:first-child { color: #ff8c00; padding-right: 12px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sq-panel td:last-child { text-align: right; color: #ffd580; }
|
||||
.station-row { cursor: pointer; touch-action: manipulation; }
|
||||
.station-row:active td { background: rgba(255,140,0,0.12); }
|
||||
.station-row td:first-child { color: #ffd580; }
|
||||
|
||||
/* Station detail view */
|
||||
.station-view { display: none; }
|
||||
.station-view.active { display: block; }
|
||||
.station-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; border-bottom: 1px solid #3a2200; }
|
||||
.station-title { font-size: 0.85rem; color: #ffd580; font-weight: bold; }
|
||||
.back-btn { font-family: inherit; font-size: 0.70rem; background: transparent; border: 1px solid #5a3300; color: #ff8c00; padding: 6px 10px; cursor: pointer; touch-action: manipulation; }
|
||||
.station-body { padding: 6px 10px; max-height: min(260px, 45vh); overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
||||
.detail-section { color: #ff8c00; font-size: 0.65rem; letter-spacing: 0.1em; margin: 8px 0 3px; }
|
||||
.detail-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 0.70rem; border-bottom: 1px solid #1a0f00; }
|
||||
.detail-label { color: #7a5500; }
|
||||
.detail-value { color: #ffd580; }
|
||||
.squares-list { font-size: 0.65rem; color: #7a5500; line-height: 1.8; padding-top: 2px; }
|
||||
.loading-msg { padding: 12px 10px; font-size: 0.70rem; color: #7a5500; }
|
||||
|
||||
/* Squares feed panel */
|
||||
#squares-panel {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 47px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
background: rgba(13,9,0,0.97);
|
||||
border: 1px solid #5a3300;
|
||||
width: min(340px, 95vw);
|
||||
max-height: min(420px, 70vh);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
#squares-panel.open { display: block; }
|
||||
.sq-feed-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 12px; border-bottom: 1px solid #3a2200;
|
||||
font-size: 0.72rem; color: #ff8c00; letter-spacing: 0.1em;
|
||||
}
|
||||
.sq-feed-close { cursor: pointer; color: #7a5500; font-size: 1rem; line-height: 1; }
|
||||
.sq-feed-close:hover { color: #ff8c00; }
|
||||
.sq-feed-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 5px 12px; border-bottom: 1px solid #1a0f00;
|
||||
cursor: pointer; font-size: 0.72rem;
|
||||
}
|
||||
.sq-feed-row:hover { background: rgba(255,140,0,0.1); }
|
||||
.sq-feed-call { color: #ff8c00; letter-spacing: 0.05em; }
|
||||
.sq-feed-meta { color: #7a5500; font-size: 0.65rem; text-align: right; }
|
||||
.sq-feed-dist { color: #ffd580; }
|
||||
|
||||
/* Clickable squares count */
|
||||
#sq-count { cursor: pointer; text-decoration: underline dotted #5a3300; }
|
||||
#sq-count:hover { color: #ff8c00; }
|
||||
|
||||
/* Leaflet overrides */
|
||||
.leaflet-popup-content-wrapper { background: transparent !important; box-shadow: 0 4px 24px rgba(0,0,0,0.7) !important; border-radius: 0 !important; padding: 0 !important; }
|
||||
.leaflet-popup-content { margin: 0 !important; }
|
||||
.leaflet-popup-tip-container { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">heard<span>log</span> — <em>APRS RF COVERAGE</em></div>
|
||||
<div class="controls">
|
||||
<div class="stat">SQUARES <strong id="sq-count" onclick="toggleSquaresPanel()">???</strong></div>
|
||||
<div class="stat">POSITIONS <strong id="pt-count">???</strong></div>
|
||||
<div class="stat">RX <strong>SA6ANW-1</strong></div>
|
||||
<button class="btn" onclick="refresh()">??? REFRESH</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="map"></div>
|
||||
<div id="squares-panel">
|
||||
<div class="sq-feed-header">
|
||||
RECENT SQUARES
|
||||
<span class="sq-feed-close" onclick="toggleSquaresPanel()">×</span>
|
||||
</div>
|
||||
<div id="squares-feed-list"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '';
|
||||
const ZOOM_SQUARES_MIN = 7;
|
||||
const ZOOM_POINTS_MIN = 14;
|
||||
const RX_LAT = 58.35, RX_LON = 14.05, RX_CALL = 'SA6ANW-1';
|
||||
|
||||
const map = L.map('map', { zoomControl: true }).setView([RX_LAT, RX_LON], 8);
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors', maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
let squareDotLayer = L.layerGroup().addTo(map);
|
||||
let squareRectLayer = L.layerGroup().addTo(map);
|
||||
let pointLayer = L.layerGroup().addTo(map);
|
||||
let squaresData = [], pointsData = [], digiCatalog = {}, rxMarker = null;
|
||||
|
||||
const rxIcon = L.divIcon({ className: '', html: '<div class="rx-marker"></div>', iconSize: [14,14], iconAnchor: [7,7] });
|
||||
rxMarker = L.marker([RX_LAT, RX_LON], { icon: rxIcon, zIndexOffset: 1000 }).addTo(map);
|
||||
|
||||
async function fetchAll() {
|
||||
const [sqRes, ptRes, dgRes, rxRes] = await Promise.all([
|
||||
fetch(`${API}/api/coverage/squares`),
|
||||
fetch(`${API}/api/coverage/points`),
|
||||
fetch(`${API}/api/coverage/digis`),
|
||||
fetch(`${API}/api/coverage/rx-stats`),
|
||||
]);
|
||||
squaresData = (await sqRes.json()).squares || [];
|
||||
pointsData = (await ptRes.json()).points || [];
|
||||
digiCatalog = (await dgRes.json()).digis || {};
|
||||
const rxPaths = (await rxRes.json()).paths || [];
|
||||
buildRxPopup(rxPaths);
|
||||
document.getElementById('sq-count').textContent = squaresData.length;
|
||||
document.getElementById('pt-count').textContent = pointsData.length;
|
||||
renderLayers();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Popup HTML ??? square view + station view both in DOM, toggled with CSS
|
||||
// ---------------------------------------------------------------------------
|
||||
function makePopupHTML(sq) {
|
||||
const pathRows = sq.paths.map(p => {
|
||||
const raw = p.path || 'DIRECT';
|
||||
const hops = raw === 'DIRECT' ? ['DIRECT'] : raw.split(',');
|
||||
const coloredHops = hops.map(hop =>
|
||||
(hop === 'DIRECT' || digiCatalog[hop.trim()])
|
||||
? `<span style="color:#ff8c00">${hop}</span>`
|
||||
: `<span style="color:#ff2200" title="No position known">${hop}</span>`
|
||||
).join(',');
|
||||
return `<tr><td title="${raw}">${coloredHops}</td><td>${p.count}</td></tr>`;
|
||||
}).join('');
|
||||
|
||||
const stationRows = (sq.stations||[]).map(s =>
|
||||
`<tr class="station-row" onclick="showStation('${sq.square}','${s.call}')">
|
||||
<td>${s.call}</td><td>${s.count}</td>
|
||||
</tr>`
|
||||
).join('');
|
||||
|
||||
return `<div class="sq-popup">
|
||||
|
||||
<!-- Square overview -->
|
||||
<div id="sq-view-${sq.square}">
|
||||
<div class="sq-popup-header">
|
||||
<span class="sq-popup-title">${sq.square}</span>
|
||||
<span class="sq-popup-sub">${sq.hits} frames</span>
|
||||
</div>
|
||||
<div class="sq-tabs">
|
||||
<div class="sq-tab active" onclick="switchTab('${sq.square}','paths')">PATHS</div>
|
||||
<div class="sq-tab" onclick="switchTab('${sq.square}','stations')">STATIONS</div>
|
||||
</div>
|
||||
<div id="${sq.square}-paths" class="sq-panel active"><table>${pathRows}</table></div>
|
||||
<div id="${sq.square}-stations" class="sq-panel"><table>${stationRows}</table></div>
|
||||
</div>
|
||||
|
||||
<!-- Station detail (hidden until clicked) -->
|
||||
<div id="st-view-${sq.square}" class="station-view">
|
||||
<div class="station-header">
|
||||
<span class="station-title" id="st-title-${sq.square}"></span>
|
||||
<button class="back-btn" onclick="backToSquare('${sq.square}')">← BACK</button>
|
||||
</div>
|
||||
<div class="station-body" id="st-body-${sq.square}">
|
||||
<div class="loading-msg">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function switchTab(sq, tab) {
|
||||
['paths','stations'].forEach(t => {
|
||||
document.getElementById(`${sq}-${t}`)?.classList.toggle('active', t === tab);
|
||||
});
|
||||
const tabs = document.querySelectorAll(`#sq-view-${sq} .sq-tab`);
|
||||
tabs.forEach((t,i) => t.classList.toggle('active', (i===0&&tab==='paths')||(i===1&&tab==='stations')));
|
||||
}
|
||||
|
||||
function backToSquare(sqId) {
|
||||
document.getElementById(`sq-view-${sqId}`).style.display = '';
|
||||
document.getElementById(`st-view-${sqId}`).classList.remove('active');
|
||||
}
|
||||
|
||||
async function showStation(sqId, callsign) {
|
||||
// Switch views
|
||||
document.getElementById(`sq-view-${sqId}`).style.display = 'none';
|
||||
const stView = document.getElementById(`st-view-${sqId}`);
|
||||
stView.classList.add('active');
|
||||
document.getElementById(`st-title-${sqId}`).textContent = callsign;
|
||||
const body = document.getElementById(`st-body-${sqId}`);
|
||||
body.innerHTML = '<div class="loading-msg">Loading...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/api/coverage/station/${encodeURIComponent(callsign)}`);
|
||||
const d = await res.json();
|
||||
const fmt = iso => iso ? new Date(iso).toLocaleString('sv-SE',{dateStyle:'short',timeStyle:'short'}) : '???';
|
||||
const topPaths = (d.top_paths||[]).map(p =>
|
||||
`<div class="detail-row"><span class="detail-label">${p.path}</span><span class="detail-value">${p.count}</span></div>`
|
||||
).join('');
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="detail-section">STATISTIK</div>
|
||||
<div class="detail-row"><span class="detail-label">Frames totalt</span><span class="detail-value">${d.total_frames}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">Unique positions</span><span class="detail-value">${d.unique_positions}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">Unique paths</span><span class="detail-value">${d.unique_paths}</span></div>
|
||||
<div class="detail-section">DISTANCE (LINE OF SIGHT)</div>
|
||||
<div class="detail-row"><span class="detail-label">Closest</span><span class="detail-value">${d.distance_min_km??'???'} km</span></div>
|
||||
<div class="detail-row"><span class="detail-label">Farthest</span><span class="detail-value">${d.distance_max_km??'???'} km</span></div>
|
||||
<div class="detail-row"><span class="detail-label">Average</span><span class="detail-value">${d.distance_avg_km??'???'} km</span></div>
|
||||
<div class="detail-section">TIMESTAMPS</div>
|
||||
<div class="detail-row"><span class="detail-label">First heard</span><span class="detail-value">${fmt(d.first_heard)}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">Last heard</span><span class="detail-value">${fmt(d.last_heard)}</span></div>
|
||||
<div class="detail-section">TOP PATHS</div>
|
||||
${topPaths}
|
||||
<div class="detail-section">SQUARES</div>
|
||||
<div class="squares-list">${(d.squares||[]).join(' ') || '???'}</div>`;
|
||||
} catch(e) {
|
||||
body.innerHTML = '<div class="loading-msg">Failed to load data.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
function makeStationPopup(callsign) {
|
||||
const id = 'pt-' + callsign.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const html = `<div class="sq-popup">
|
||||
<div id="sq-view-${id}" style="display:none"></div>
|
||||
<div id="st-view-${id}" class="station-view active">
|
||||
<div class="station-header">
|
||||
<span class="station-title" id="st-title-${id}">${callsign}</span>
|
||||
</div>
|
||||
<div class="station-body" id="st-body-${id}">
|
||||
<div class="loading-msg">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const popup = L.popup({ maxWidth: 320 }).setContent(html);
|
||||
popup.on('add', () => {
|
||||
fetchStationInto(id, callsign);
|
||||
});
|
||||
return popup;
|
||||
}
|
||||
|
||||
async function fetchStationInto(id, callsign) {
|
||||
const body = document.getElementById(`st-body-${id}`);
|
||||
if (!body) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/coverage/station/${encodeURIComponent(callsign)}`);
|
||||
const d = await res.json();
|
||||
const fmt = iso => iso ? new Date(iso).toLocaleString('sv-SE',{dateStyle:'short',timeStyle:'short'}) : '-';
|
||||
const topPaths = (d.top_paths||[]).map(p =>
|
||||
`<div class="detail-row"><span class="detail-label">${p.path}</span><span class="detail-value">${p.count}</span></div>`
|
||||
).join('');
|
||||
body.innerHTML = `
|
||||
<div class="detail-section">STATISTICS</div>
|
||||
<div class="detail-row"><span class="detail-label">Total frames</span><span class="detail-value">${d.total_frames}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">Unique positions</span><span class="detail-value">${d.unique_positions}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">Unique paths</span><span class="detail-value">${d.unique_paths}</span></div>
|
||||
<div class="detail-section">DISTANCE (LINE OF SIGHT)</div>
|
||||
<div class="detail-row"><span class="detail-label">Closest</span><span class="detail-value">${d.distance_min_km??'-'} km</span></div>
|
||||
<div class="detail-row"><span class="detail-label">Farthest</span><span class="detail-value">${d.distance_max_km??'-'} km</span></div>
|
||||
<div class="detail-row"><span class="detail-label">Average</span><span class="detail-value">${d.distance_avg_km??'-'} km</span></div>
|
||||
<div class="detail-section">TIMESTAMPS</div>
|
||||
<div class="detail-row"><span class="detail-label">First heard</span><span class="detail-value">${fmt(d.first_heard)}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">Last heard</span><span class="detail-value">${fmt(d.last_heard)}</span></div>
|
||||
<div class="detail-section">TOP PATHS</div>
|
||||
${topPaths}
|
||||
<div class="detail-section">SQUARES</div>
|
||||
<div class="squares-list">${(d.squares||[]).join(' ') || '-'}</div>`;
|
||||
} catch(e) {
|
||||
body.innerHTML = '<div class="loading-msg">Failed to load data.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function bindSquarePopup(layer, sq) {
|
||||
layer.bindPopup(makePopupHTML(sq), { maxWidth: 320 });
|
||||
}
|
||||
|
||||
function renderLayers() {
|
||||
squareDotLayer.clearLayers();
|
||||
squareRectLayer.clearLayers();
|
||||
pointLayer.clearLayers();
|
||||
const zoom = map.getZoom();
|
||||
|
||||
if (zoom >= ZOOM_POINTS_MIN) {
|
||||
for (const pt of pointsData) {
|
||||
const dot = L.circleMarker([pt.lat, pt.lon], {
|
||||
radius: 4, color: '#ff8c00', weight: 1, fillColor: '#ffb347', fillOpacity: 0.8,
|
||||
});
|
||||
dot.bindPopup(makeStationPopup(pt.call), { maxWidth: 320 });
|
||||
pointLayer.addLayer(dot);
|
||||
}
|
||||
} else if (zoom >= ZOOM_SQUARES_MIN) {
|
||||
for (const sq of squaresData) {
|
||||
const rect = L.rectangle(
|
||||
[[sq.lat_min, sq.lon_min], [sq.lat_max, sq.lon_max]],
|
||||
{ color: '#ff8c00', weight: 1, opacity: 0.9, fillColor: '#ff8c00', fillOpacity: 0.28 }
|
||||
);
|
||||
bindSquarePopup(rect, sq);
|
||||
rect._sq = sq;
|
||||
rect.on('popupopen', () => { rect.setStyle({ weight: 3, fillOpacity: 0.45 }); setTimeout(() => attachPathHover(sq), 50); });
|
||||
rect.on('popupclose', () => { rect.setStyle({ weight: 1, fillOpacity: 0.28 }); clearPathLines(); });
|
||||
squareRectLayer.addLayer(rect);
|
||||
}
|
||||
} else {
|
||||
for (const sq of squaresData) {
|
||||
const lat = (sq.lat_min + sq.lat_max) / 2;
|
||||
const lon = (sq.lon_min + sq.lon_max) / 2;
|
||||
const dot = L.circleMarker([lat, lon], {
|
||||
radius: 5, color: '#ff8c00', weight: 1, fillColor: '#ffb347', fillOpacity: 0.9,
|
||||
});
|
||||
bindSquarePopup(dot, sq);
|
||||
dot._sq = sq;
|
||||
dot.on('popupopen', () => setTimeout(() => attachPathHover(sq), 50));
|
||||
dot.on('popupclose', () => clearPathLines());
|
||||
squareDotLayer.addLayer(dot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Squares feed panel
|
||||
// ---------------------------------------------------------------------------
|
||||
function toggleSquaresPanel() {
|
||||
const panel = document.getElementById('squares-panel');
|
||||
panel.classList.toggle('open');
|
||||
if (panel.classList.contains('open')) renderSquaresFeed();
|
||||
}
|
||||
|
||||
function renderSquaresFeed() {
|
||||
const list = document.getElementById('squares-feed-list');
|
||||
const sorted = [...squaresData].sort((a, b) => {
|
||||
if (!a.first_seen) return 1;
|
||||
if (!b.first_seen) return -1;
|
||||
return b.first_seen.localeCompare(a.first_seen);
|
||||
});
|
||||
|
||||
list.innerHTML = sorted.map(sq => {
|
||||
const ts = sq.first_seen
|
||||
? new Date(sq.first_seen).toLocaleString('sv-SE', {dateStyle:'short', timeStyle:'short'})
|
||||
: '-';
|
||||
return `<div class="sq-feed-row" onclick="focusSquare('${sq.square}')">
|
||||
<span class="sq-feed-call">${sq.square}</span>
|
||||
<span class="sq-feed-meta">
|
||||
<span class="sq-feed-dist">${sq.dist_km} km</span><br/>
|
||||
${ts}
|
||||
</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function focusSquare(sqId) {
|
||||
document.getElementById('squares-panel').classList.remove('open');
|
||||
const sq = squaresData.find(s => s.square === sqId);
|
||||
if (!sq) return;
|
||||
const lat = (sq.lat_min + sq.lat_max) / 2;
|
||||
const lon = (sq.lon_min + sq.lon_max) / 2;
|
||||
|
||||
// Zoom to square level and center
|
||||
map.setView([lat, lon], Math.max(map.getZoom(), 10));
|
||||
|
||||
// Open popup after layers have rendered
|
||||
setTimeout(() => {
|
||||
let found = false;
|
||||
squareRectLayer.eachLayer(l => {
|
||||
if (l._sq && l._sq.square === sqId) { l.openPopup(); found = true; }
|
||||
});
|
||||
if (!found) {
|
||||
squareDotLayer.eachLayer(l => {
|
||||
if (l._sq && l._sq.square === sqId) { l.openPopup(); }
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RX station popup
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildRxPopup(paths) {
|
||||
const rows = paths.map(p => {
|
||||
const hops = p.path === 'DIRECT' ? ['DIRECT'] : p.path.split(',');
|
||||
const coloredHops = hops.map(hop =>
|
||||
(hop === 'DIRECT' || digiCatalog[hop.trim()])
|
||||
? `<span style="color:#ff8c00">${hop}</span>`
|
||||
: `<span style="color:#ff2200" title="No position known">${hop}</span>`
|
||||
).join(',');
|
||||
return `<tr class="rx-path-row" data-path="${p.path}">
|
||||
<td style="padding-right:14px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${p.path}">${coloredHops}</td>
|
||||
<td style="text-align:right;color:#ffd580;white-space:nowrap">${p.count}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const html = `<div class="sq-popup" style="width:280px">
|
||||
<div class="sq-popup-header">
|
||||
<span class="sq-popup-title">${RX_CALL}</span>
|
||||
<span class="sq-popup-sub">RX station</span>
|
||||
</div>
|
||||
<div style="padding:4px 10px 2px;font-size:0.63rem;letter-spacing:0.1em;color:#ff8c00;border-bottom:1px solid #3a2200">PATHS HEARD</div>
|
||||
<div style="padding:6px 10px;max-height:280px;overflow-y:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:0.68rem">${rows}</table>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (rxMarker) rxMarker.unbindPopup();
|
||||
if (rxMarker) {
|
||||
const popup = L.popup({ maxWidth: 320 }).setContent(html);
|
||||
popup.on('add', () => {
|
||||
document.querySelectorAll('.rx-path-row').forEach(tr => {
|
||||
const path = tr.dataset.path;
|
||||
tr.style.cursor = 'default';
|
||||
tr.addEventListener('mouseenter', () => drawRxPath(path));
|
||||
tr.addEventListener('touchstart', e => { e.stopPropagation(); drawRxPath(path); }, { passive: true });
|
||||
tr.addEventListener('mouseleave', clearPathLines);
|
||||
tr.addEventListener('touchend', () => setTimeout(clearPathLines, 2000), { passive: true });
|
||||
});
|
||||
});
|
||||
rxMarker.bindPopup(popup);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path drawing on hover
|
||||
// ---------------------------------------------------------------------------
|
||||
let pathLines = [];
|
||||
|
||||
function drawRxPath(rawPath) {
|
||||
clearPathLines();
|
||||
if (!rawPath || rawPath === 'DIRECT') return;
|
||||
|
||||
const waypoints = [];
|
||||
rawPath.split(',').forEach(hop => {
|
||||
const d = digiCatalog[hop.trim()];
|
||||
if (d) waypoints.push({ lat: d.lat, lon: d.lon });
|
||||
});
|
||||
waypoints.push({ lat: RX_LAT, lon: RX_LON });
|
||||
|
||||
if (waypoints.length < 2) return;
|
||||
|
||||
const line = L.polyline(waypoints.map(w => [w.lat, w.lon]), {
|
||||
color: '#000000', weight: 2, opacity: 0.85, dashArray: '6,4',
|
||||
}).addTo(map);
|
||||
pathLines.push(line);
|
||||
|
||||
waypoints.forEach(w => {
|
||||
const m = L.circleMarker([w.lat, w.lon], {
|
||||
radius: 4, color: '#000000', weight: 2,
|
||||
fillColor: '#0d0900', fillOpacity: 0.6,
|
||||
}).addTo(map);
|
||||
pathLines.push(m);
|
||||
});
|
||||
}
|
||||
|
||||
function clearPathLines() {
|
||||
pathLines.forEach(l => map.removeLayer(l));
|
||||
pathLines = [];
|
||||
}
|
||||
|
||||
function drawPath(rawPath, stationLat, stationLon) {
|
||||
clearPathLines();
|
||||
|
||||
// Build waypoints: station ??? each digi ??? RX
|
||||
const waypoints = [{ lat: stationLat, lon: stationLon, label: 'Station' }];
|
||||
|
||||
if (rawPath && rawPath !== 'DIRECT') {
|
||||
rawPath.split(',').forEach(hop => {
|
||||
const d = digiCatalog[hop.trim()];
|
||||
if (d) waypoints.push({ lat: d.lat, lon: d.lon, label: hop.trim() });
|
||||
});
|
||||
}
|
||||
|
||||
waypoints.push({ lat: RX_LAT, lon: RX_LON, label: RX_CALL });
|
||||
|
||||
if (waypoints.length < 2) return;
|
||||
|
||||
// Draw polyline
|
||||
const latlngs = waypoints.map(w => [w.lat, w.lon]);
|
||||
const line = L.polyline(latlngs, {
|
||||
color: '#000000', weight: 2, opacity: 0.85,
|
||||
dashArray: '6,4',
|
||||
}).addTo(map);
|
||||
pathLines.push(line);
|
||||
|
||||
// Draw circle markers at each waypoint
|
||||
waypoints.forEach((w, i) => {
|
||||
const isRx = i === waypoints.length - 1;
|
||||
const isStation = i === 0;
|
||||
const m = L.circleMarker([w.lat, w.lon], {
|
||||
radius: isRx || isStation ? 5 : 4,
|
||||
color: '#000000',
|
||||
weight: 2,
|
||||
fillColor: isRx ? '#ffe066' : '#0d0900',
|
||||
fillOpacity: isRx ? 1 : 0.6,
|
||||
}).addTo(map);
|
||||
pathLines.push(m);
|
||||
});
|
||||
}
|
||||
|
||||
// Attach hover to path rows after popup opens
|
||||
function attachPathHover(sq) {
|
||||
const panel = document.getElementById(`${sq.square}-paths`);
|
||||
if (!panel) return;
|
||||
const rows = panel.querySelectorAll('tr');
|
||||
rows.forEach((tr, i) => {
|
||||
const rawPath = sq.paths[i]?.path || '';
|
||||
const drawForRow = () => {
|
||||
const lat = (sq.lat_min + sq.lat_max) / 2;
|
||||
const lon = (sq.lon_min + sq.lon_max) / 2;
|
||||
drawPath(rawPath, lat, lon);
|
||||
};
|
||||
tr.addEventListener('mouseenter', drawForRow);
|
||||
tr.addEventListener('touchstart', e => { e.stopPropagation(); drawForRow(); }, { passive: true });
|
||||
tr.addEventListener('mouseleave', clearPathLines);
|
||||
tr.addEventListener('touchend', () => setTimeout(clearPathLines, 2000), { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Draggable popup
|
||||
// ---------------------------------------------------------------------------
|
||||
map.on('popupopen', e => {
|
||||
const wrapper = e.popup._wrapper;
|
||||
if (!wrapper) return;
|
||||
|
||||
const header = wrapper.querySelector('.sq-popup-header, .station-header');
|
||||
if (!header) return;
|
||||
|
||||
header.style.cursor = 'grab';
|
||||
|
||||
let startX, startY, origLeft, origTop;
|
||||
|
||||
function startDrag(clientX, clientY) {
|
||||
header.style.cursor = 'grabbing';
|
||||
const el = wrapper.parentElement;
|
||||
startX = clientX;
|
||||
startY = clientY;
|
||||
origLeft = el.offsetLeft;
|
||||
origTop = el.offsetTop;
|
||||
|
||||
function onMove(e) {
|
||||
const cx = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const cy = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
el.style.left = (origLeft + cx - startX) + 'px';
|
||||
el.style.top = (origTop + cy - startY) + 'px';
|
||||
}
|
||||
function onUp() {
|
||||
header.style.cursor = 'grab';
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.removeEventListener('touchmove', onMove);
|
||||
document.removeEventListener('touchend', onUp);
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
document.addEventListener('touchmove', onMove, { passive: true });
|
||||
document.addEventListener('touchend', onUp);
|
||||
}
|
||||
|
||||
header.addEventListener('mousedown', e => { e.preventDefault(); e.stopPropagation(); startDrag(e.clientX, e.clientY); });
|
||||
header.addEventListener('touchstart', e => { e.stopPropagation(); startDrag(e.touches[0].clientX, e.touches[0].clientY); }, { passive: true });
|
||||
});
|
||||
|
||||
map.on('zoomend', renderLayers);
|
||||
|
||||
async function refresh() {
|
||||
document.getElementById('sq-count').textContent = '???';
|
||||
document.getElementById('pt-count').textContent = '???';
|
||||
await fetchAll();
|
||||
}
|
||||
|
||||
fetchAll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
14
web/nginx.conf
Normal file
14
web/nginx.conf
Normal file
@@ -0,0 +1,14 @@
|
||||
server {
|
||||
listen 80;
|
||||
charset utf-8;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
add_header Content-Type "text/html; charset=utf-8";
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://collector:8080/;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user