Compare commits

...

2 Commits

Author SHA1 Message Date
root
9171a5134d webui 2026-05-02 18:17:37 +00:00
root
0b394c9bae webui 2026-05-02 18:16:56 +00:00
6 changed files with 1416 additions and 0 deletions

55
collector/maidenhead.py Normal file
View 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),
}

View File

@@ -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
View 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
}
}

View File

@@ -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
View 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> &mdash; <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()">&times;</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: '&copy; 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}')">&larr; 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(' &nbsp;') || '???'}</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(' &nbsp;') || '-'}</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
View 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/;
}
}