diff --git a/ONBOARDING.md b/ONBOARDING.md index 23cf8990..8e8bff3b 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -3,9 +3,9 @@ Welcome. This repo powers RETINA, a passive-radar system: a network of receiver nodes detect aircraft by listening to reflections of broadcast transmitters (bistatic radar), and the backend turns those detections into tracks and live -positions shown on a web map. It started life as "Tower Finder" (a tool to find -suitable broadcast illuminators near a receiver), which is still one of the -surfaces. +positions shown on a web map. It started life as "Tower Finder", a tool to find +suitable broadcast illuminators near a receiver; that feature now lives entirely +in tower-finder-service, both its API and its UI. Read this top-to-bottom once; it should get you from a fresh clone to running the whole thing locally and understanding how the pieces fit. For deeper dives, @@ -21,7 +21,11 @@ One FastAPI backend serves several React front-ends, distinguished by subdomain: | **testmap** | Live aircraft map fed by the simulation fleet (synthetic nodes) — the main dev/demo surface. `testmap.retina.fm` is served by the **staging** droplet, the only environment still running a fleet. | | **map** | Production live map showing only real radar nodes. | | **dashboard** | Admin app (auth required): node ownership, claim codes, MLAT verification, metrics. | -| **Tower Finder** | The original illuminator search. The SPA is here; the `/api/towers`, `/api/elevation` and `/api/config` API is **tower-finder-service** (separate repo and container), which nginx proxies to on every vhost. This backend no longer implements it. | + +Illuminator search is deliberately absent from that table: **tower-finder-service** +(separate repo and container) owns the API and the UI both, and serves +`towers.retina.fm` from its own edge. Our vhosts only proxy `/api/towers`, +`/api/elevation` and `/api/config` to it. Receiver nodes connect over TCP and stream detection frames. The pipeline (tracker → geolocator) turns frames into aircraft positions, broadcast to the diff --git a/README.md b/README.md index 3dff08b6..de0fe2c1 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,25 @@ This repo powers RETINA, a passive-radar system: receiver nodes detect aircraft from reflections of broadcast transmitters, and the backend turns those -detections into live tracks shown on a web map. "Tower Finder" — the original -illuminator-search feature — is one of several surfaces (along with the live map -and the admin dashboard), and is the one whose API now lives in its own service. +detections into live tracks shown on a web map. Its surfaces are the live map +and the admin dashboard. > **New here?** Start with [`ONBOARDING.md`](ONBOARDING.md) for the full picture > and local setup, and [`docs/architecture.md`](docs/architecture.md) for how the > pieces fit together. -## Tower Finder feature +## Illuminator search -Web application that helps passive radar operators find suitable broadcast tower -illuminators near their location: given coordinates, it returns nearby FM/VHF/UHF -transmitters ranked by suitability for passive radar use. +Finding a suitable broadcast illuminator near a receiver is **tower-finder-service**, +a separate repo and container. It owns both halves: the API (`/api/towers`, +`/api/elevation`, `/api/config`) and the UI, which it serves itself on +`towers.retina.fm` through its own edge. -This repo owns the SPA (`frontend/`) and the nginx routing. The search API itself -(`/api/towers`, plus `/api/elevation` and `/api/config`) is served by -**tower-finder-service**, a separate repo and container that every vhost is -proxied to; the monolith's own copy of that stack was deleted once the proxy went -live, so there is one implementation and one ranking answer. +What remains here is the proxy seam. `api.retina.fm/towers` forwards to the +service for callers that want a clean public API name, and the other vhosts +still forward `/api/towers`, `/api/elevation` and `/api/config` so that a +request arriving at one of them reaches the single implementation rather than a +404 from this backend. ## Project Structure diff --git a/docs/architecture.md b/docs/architecture.md index 61df84ba..6d918937 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,9 +12,9 @@ only by subdomain, resolved client-side in `frontend/src/utils/domains.ts`: staging and local stacks run a fleet, so `testmap.retina.fm` is served by the staging droplet rather than production. - **map** (`map.retina.fm`) — production live map, real radar nodes only. -- **Tower Finder** — `/api/towers` illuminator search (the original feature). - The SPA is this repo's; the API is proxied to tower-finder-service, which owns - the only implementation since the monolith's copy was deleted. +- **Illuminator search** — not a surface of this repo. tower-finder-service owns + both the API and the UI, and serves `towers.retina.fm` from its own edge. The + vhosts here proxy `/api/towers`, `/api/elevation` and `/api/config` to it. - **dashboard** (`dashboard/`, separate SPA) — admin: node ownership, claim codes, MLAT verification, metrics. Auth required. diff --git a/frontend/e2e/live-map.spec.ts b/frontend/e2e/live-map.spec.ts index 4c4727c9..8274e0ea 100644 --- a/frontend/e2e/live-map.spec.ts +++ b/frontend/e2e/live-map.spec.ts @@ -103,8 +103,8 @@ async function rowsOrSkip(page: Page) { test.describe("Live Map — page identity", () => { test("page title contains RETINA", async ({ page }) => { await page.goto(BASE); - // The HTML is static "Tower Finder" for all domains; - // the domain identity is exposed in the h1 element instead. + // Asserted on the h1 rather than the HTML <title>, which is static across + // every domain this bundle serves. await expect(page.locator("h1")).toContainText(/RETINA/i); }); diff --git a/frontend/index.html b/frontend/index.html index cf1462d2..0bc4525b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Tower Finder + RETINA t.distance_class === "Ideal").length; - const bands = [...new Set(towers.map((t) => t.band))]; - const best = towers[0]; - - return ( -
-
- {towers.length} - Towers Found -
-
- {ideal} - Ideal Range -
-
- {bands.join(", ")} - Bands -
- {best && ( -
- {best.callsign || "—"} - Top Pick — {best.distance_km} km -
- )} -
- ); -} - export default function App() { if (IS_TEST_RADAR_ROUTE && TEST_RADAR_ENABLED) { return ( @@ -70,50 +35,16 @@ export default function App() { } function MainApp() { - const [towers, setTowers] = useState([]); - const [query, setQuery] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [highlighted, setHighlighted] = useState(null); - - const [activeTab, setActiveTab] = useState(isMapDomain ? "live" : "towers"); - - async function handleSearch({ lat, lon, altitude, source, frequencies }) { - setLoading(true); - setError(null); - setTowers([]); - setQuery(null); - - try { - const data = await fetchTowers(lat, lon, altitude, 20, source, frequencies || []); - setTowers(data.towers); - setQuery(data.query); - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - } - - // Keep the map mounted once it has been opened so it doesn't re-initialise - const [liveEverOpened, setLiveEverOpened] = useState(isMapDomain); + const [activeTab, setActiveTab] = useState("live"); return ( -
+
-

{isMapDomain ? "RETINA" : "Tower Finder"}

- {isMapDomain ? "Passive Radar Live Map" : "Passive Radar Illuminator Search"} +

RETINA

+ Passive Radar Live Map
- {activeTab === "towers" && ( - <> -
- - -
- - {error &&
{error}
} - - {loading && ( -
-
-
-
-
-

- Querying broadcast licence database — this may take up to a minute… -

-
- )} - - - - {towers.length > 0 && ( - - )} - - {!loading && query && towers.length === 0 && ( -

- No suitable broadcast towers found within 80 km. -

- )} - - )} - - {/* Live map: mounted once ever, hidden when inactive to preserve WebSocket state */} + {/* Hidden rather than unmounted when inactive, to preserve WebSocket state */} Loading map…
}>
- {liveEverOpened && } +
{activeTab === "physics" && } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 82729671..a6ff683f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -8,36 +8,6 @@ let mlatVerificationCache: unknown = null; let mlatVerificationCacheTs = 0; let mlatVerificationInflight: Promise | null = null; -export async function fetchTowers(lat, lon, altitude = 0, limit = 20, source = "auto", frequencies = []) { - const params = new URLSearchParams({ - lat: String(lat), - lon: String(lon), - altitude: String(altitude), - limit: String(limit), - source, - }); - if (frequencies.length > 0) { - params.set("frequencies", frequencies.join(",")); - } - const res = await fetch(`${API_BASE}/towers?${params}`); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.detail || `Request failed (${res.status})`); - } - return res.json(); -} - -export async function fetchElevation(lat, lon, signal?: AbortSignal) { - const params = new URLSearchParams({ - lat: String(lat), - lon: String(lon), - }); - const res = await fetch(`${API_BASE}/elevation?${params}`, { signal }); - if (!res.ok) return null; - const data = await res.json(); - return data.elevation_m; -} - export async function fetchNodeDetectionRange(nodeRef: string, signal?: AbortSignal) { if (!nodeRef) return null; // The route is keyed on the public ref; a node_id 404s. diff --git a/frontend/src/components/PhysicsSettings.css b/frontend/src/components/PhysicsSettings.css index dacfb350..be4b81a4 100644 --- a/frontend/src/components/PhysicsSettings.css +++ b/frontend/src/components/PhysicsSettings.css @@ -1,7 +1,7 @@ /* ── Physics object layer ───────────────────────────────────────────────── The map surface's sibling tab, on the same dash tokens defined in - map-surface.css. It only renders inside `.app.map-surface` (App gates the - tab on isMapDomain), so the var() calls always resolve. + map-surface.css. App always sets `.app.map-surface`, so the var() calls + always resolve. Object-class colours are NOT set here: they come from mapPalette.ts via the component, so this legend and the map cannot drift apart. */ diff --git a/frontend/src/components/ResultsTable.css b/frontend/src/components/ResultsTable.css deleted file mode 100644 index d9601e47..00000000 --- a/frontend/src/components/ResultsTable.css +++ /dev/null @@ -1,114 +0,0 @@ -.results-wrap { - background: #fff; - border-radius: 12px; - padding: 24px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); -} - -.results-wrap h2 { - font-size: 0.95rem; - margin-bottom: 16px; - color: #1a1a2e; - font-weight: 600; - display: flex; - align-items: center; - gap: 8px; -} - -.results-count { - background: #f0f2f5; - color: #6b7280; - font-size: 0.75rem; - font-weight: 600; - padding: 2px 9px; - border-radius: 10px; -} - -.table-scroll { - overflow-x: auto; -} - -.results-table { - width: 100%; - border-collapse: collapse; - font-size: 0.83rem; -} - -.results-table th { - text-align: left; - padding: 10px 12px; - color: #9ca3af; - font-weight: 500; - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.05em; - border-bottom: 1px solid #f0f2f5; - white-space: nowrap; -} - -.results-table td { - padding: 11px 12px; - border-bottom: 1px solid #f8f9fa; - white-space: nowrap; - color: #374151; -} - -.results-table tbody tr { - transition: background 0.15s; -} - -.results-table tbody tr:hover { - background: #f8fafc; -} - -.results-table .rank { - font-weight: 700; - color: #1a1a2e; - font-size: 0.8rem; - width: 2em; -} - -.results-table .callsign { - font-weight: 600; - color: #1a1a2e; -} - -.results-table .location-name { - max-width: 220px; - overflow: hidden; - text-overflow: ellipsis; - color: #6b7280; - font-size: 0.8rem; -} - -.results-table .mono { - font-variant-numeric: tabular-nums; - font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; - font-size: 0.8rem; -} - -.results-table .power { - font-weight: 500; -} - -.results-table .cardinal { - color: #9ca3af; - font-size: 0.75rem; -} - -.badge { - display: inline-block; - padding: 3px 10px; - border-radius: 6px; - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.02em; -} - -.freq-match-badge { - display: inline-block; - margin-left: 4px; - color: #16a34a; - font-size: 0.72rem; - font-weight: 700; -} diff --git a/frontend/src/components/ResultsTable.tsx b/frontend/src/components/ResultsTable.tsx deleted file mode 100644 index d1fa3cb2..00000000 --- a/frontend/src/components/ResultsTable.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import "./ResultsTable.css"; - -const CLASS_COLORS = { - Ideal: "#16a34a", - Good: "#ca8a04", - Far: "#6b7280", - "Too Close": "#dc2626", -}; - -const CLASS_BG = { - Ideal: "rgba(22, 163, 74, 0.08)", - Good: "rgba(202, 138, 4, 0.08)", - Far: "rgba(107, 114, 128, 0.08)", - "Too Close": "rgba(220, 38, 38, 0.08)", -}; - -const BAND_COLORS = { - VHF: "#7c3aed", - UHF: "#0891b2", - FM: "#db2777", -}; - -const BAND_BG = { - VHF: "rgba(124, 58, 237, 0.08)", - UHF: "rgba(8, 145, 178, 0.08)", - FM: "rgba(219, 39, 119, 0.08)", -}; - -export default function ResultsTable({ towers, onHover }) { - return ( -
-

Results {towers.length}

-
- - - - - - - - - - - - - - - - - - - - - {towers.map((t) => ( - onHover(t)} - onMouseLeave={() => onHover(null)} - > - - - - - - - - - - - - - - - - ))} - -
#CallsignLocationLatLongAltitude (m)Ant. Height (m)Freq (MHz)BandEIRPDistanceBearingRx PowerSuitability
{t.rank}{t.callsign || "—"} - {t.name} - {t.state ? `, ${t.state}` : ""} - {t.latitude}{t.longitude}{t.altitude_m != null ? t.altitude_m : "—"}{t.antenna_height_m != null ? t.antenna_height_m : "—"} - {t.frequency_mhz} - {t.frequency_matched && ( - - )} - - - {t.band} - - {t.eirp_dbm} dBm{t.distance_km} km - {t.bearing_deg}° {t.bearing_cardinal} - {t.received_power_dbm} dBm - - {t.distance_class} - -
-
-
- ); -} diff --git a/frontend/src/components/SearchForm.css b/frontend/src/components/SearchForm.css deleted file mode 100644 index 4bc29b2f..00000000 --- a/frontend/src/components/SearchForm.css +++ /dev/null @@ -1,239 +0,0 @@ -.search-form { - background: #fff; - border-radius: 12px; - padding: 24px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); - display: flex; - flex-direction: column; - min-width: 0; -} - -.search-form h2 { - font-size: 0.95rem; - margin-bottom: 16px; - color: #1a1a2e; - font-weight: 600; - letter-spacing: -0.01em; -} - -.search-form label { - display: flex; - flex-direction: column; - font-size: 0.78rem; - color: #6b7280; - margin-bottom: 14px; - font-weight: 500; - letter-spacing: 0.01em; - min-width: 0; -} - -.search-form input, -.search-form select { - margin-top: 5px; - padding: 9px 12px; - border: 1px solid #e5e7eb; - border-radius: 8px; - font-size: 0.88rem; - color: #1a1a2e; - background: #fafbfc; - outline: none; - transition: border-color 0.2s, box-shadow 0.2s; - width: 100%; - min-width: 0; -} - -.search-form input:focus, -.search-form select:focus { - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); - background: #fff; -} - -.search-form input::placeholder { - color: #c9cdd4; -} - -.field-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} - -.field-row > * { - min-width: 0; -} - -.form-actions { - display: flex; - gap: 10px; - margin-top: 6px; - min-width: 0; -} - -.btn-primary, -.btn-secondary { - flex: 1; - padding: 10px 14px; - border: none; - border-radius: 8px; - font-size: 0.84rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - letter-spacing: 0.01em; - min-width: 0; -} - -.btn-primary { - background: linear-gradient(135deg, #2563eb, #3b82f6); - color: #fff; - box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25); -} - -.btn-primary:hover:not(:disabled) { - background: linear-gradient(135deg, #1d4ed8, #2563eb); - box-shadow: 0 4px 12px rgba(37, 99, 235, 0.35); - transform: translateY(-1px); -} - -.btn-secondary { - background: #f8f9fa; - color: #374151; - border: 1px solid #e5e7eb; -} - -.btn-secondary:hover:not(:disabled) { - background: #f0f1f3; - border-color: #d1d5db; -} - -.btn-primary:disabled, -.btn-secondary:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -.geo-error { - color: #dc2626; - font-size: 0.78rem; - margin-top: 8px; -} - -/* Frequency inputs */ -.freq-toggle { - margin-top: 2px; - margin-bottom: 4px; -} - -.btn-link { - background: none; - border: none; - color: #3b82f6; - font-size: 0.8rem; - font-weight: 500; - cursor: pointer; - padding: 0; - text-decoration: underline; -} - -.btn-link:hover { - color: #1d4ed8; -} - -.freq-section { - margin-bottom: 10px; -} - -.freq-label { - font-size: 0.78rem; - color: #6b7280; - font-weight: 500; - margin-bottom: 6px; - display: block; -} - -.freq-inputs { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 6px; -} - -.freq-row { - display: flex; - gap: 6px; - align-items: center; -} - -.freq-row input { - flex: 1; - padding: 7px 10px; - border: 1px solid #e5e7eb; - border-radius: 8px; - font-size: 0.84rem; - color: #1a1a2e; - background: #fafbfc; - outline: none; - transition: border-color 0.2s, box-shadow 0.2s; -} - -.freq-row input:focus { - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); - background: #fff; -} - -.btn-remove-freq { - background: none; - border: 1px solid #e5e7eb; - border-radius: 6px; - color: #9ca3af; - font-size: 1rem; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s; - padding: 0; - line-height: 1; -} - -.btn-remove-freq:hover { - color: #dc2626; - border-color: #fca5a5; - background: #fef2f2; -} - -.btn-add-freq { - background: none; - border: 1px dashed #d1d5db; - border-radius: 8px; - color: #6b7280; - font-size: 0.78rem; - padding: 6px 12px; - cursor: pointer; - width: 100%; - transition: all 0.2s; -} - -.btn-add-freq:hover { - border-color: #3b82f6; - color: #3b82f6; - background: #eff6ff; -} - -@media (max-width: 720px) { - .field-row, - .form-actions { - grid-template-columns: 1fr; - flex-direction: column; - } - - .search-form { - padding: 20px; - } -} diff --git a/frontend/src/components/SearchForm.tsx b/frontend/src/components/SearchForm.tsx deleted file mode 100644 index 25504a7e..00000000 --- a/frontend/src/components/SearchForm.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { useState, useEffect, useRef } from "react"; -import { fetchElevation } from "../api"; -import "./SearchForm.css"; - -export default function SearchForm({ onSearch, loading }) { - const [lat, setLat] = useState(""); - const [lon, setLon] = useState(""); - const [altitude, setAltitude] = useState(""); - const [source, setSource] = useState("auto"); - const [geoError, setGeoError] = useState(null); - const [geoLoading, setGeoLoading] = useState(false); - const altitudeManual = useRef(false); - const [frequencies, setFrequencies] = useState([""]); - const [showFrequencies, setShowFrequencies] = useState(false); - - // Auto-lookup elevation when lat/lon change and altitude hasn't been manually set - useEffect(() => { - if (altitudeManual.current) return; - const parsedLat = parseFloat(lat); - const parsedLon = parseFloat(lon); - if (isNaN(parsedLat) || isNaN(parsedLon)) return; - - // Debounced + aborted: this fired one un-cancellable request per - // KEYSTROKE in the lat/lon inputs. - const controller = new AbortController(); - const timer = setTimeout(() => { - fetchElevation(parsedLat, parsedLon, controller.signal).then((elev) => { - if (!controller.signal.aborted && elev != null && !altitudeManual.current) { - setAltitude(Math.round(elev).toString()); - } - }).catch(() => {}); - }, 400); - return () => { controller.abort(); clearTimeout(timer); }; - }, [lat, lon]); - - function handleSubmit(e) { - e.preventDefault(); - const parsedLat = parseFloat(lat); - const parsedLon = parseFloat(lon); - if (isNaN(parsedLat) || isNaN(parsedLon)) return; - const parsedFreqs = frequencies - .map((f) => parseFloat(f)) - .filter((f) => !isNaN(f) && f > 0); - onSearch({ - lat: parsedLat, - lon: parsedLon, - altitude: parseFloat(altitude) || 0, - source, - frequencies: parsedFreqs, - }); - } - - function useMyLocation() { - if (!navigator.geolocation) { - setGeoError("Geolocation not supported by your browser"); - return; - } - setGeoError(null); - setGeoLoading(true); - navigator.geolocation.getCurrentPosition( - (pos) => { - setGeoLoading(false); - setLat(pos.coords.latitude.toFixed(6)); - setLon(pos.coords.longitude.toFixed(6)); - if (pos.coords.altitude != null) { - setAltitude(Math.round(pos.coords.altitude).toString()); - } - }, - (err) => { - setGeoLoading(false); - const msgs = { - 1: "Location access denied — please allow location in browser settings", - 2: "Location unavailable", - 3: "Location request timed out", - }; - setGeoError(msgs[err.code] || err.message); - }, - { timeout: 10000, maximumAge: 60000, enableHighAccuracy: false } - ); - } - - return ( -
-

Location

- -
- - -
- -
- - -
- -
- -
- - {showFrequencies && ( -
- -
- {frequencies.map((freq, i) => ( -
- { - const updated = [...frequencies]; - updated[i] = e.target.value; - setFrequencies(updated); - }} - placeholder={`Freq ${i + 1} (MHz)`} - /> - {frequencies.length > 1 && ( - - )} -
- ))} -
- {frequencies.length < 10 && ( - - )} -
- )} - -
- - -
- - {geoError &&

{geoError}

} -
- ); -} diff --git a/frontend/src/components/TowerMap.css b/frontend/src/components/TowerMap.css deleted file mode 100644 index 82f0a693..00000000 --- a/frontend/src/components/TowerMap.css +++ /dev/null @@ -1,52 +0,0 @@ -.map-wrap { - background: #fff; - border-radius: 12px; - overflow: hidden; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); - min-height: 420px; -} - -.map-container { - height: 420px; - width: 100%; -} - -.tower-marker, -.user-marker { - background: none; - border: none; -} - -/* Leaflet popup overrides */ -.leaflet-popup-content-wrapper { - border-radius: 10px !important; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12) !important; - padding: 0 !important; -} - -.leaflet-popup-content { - margin: 14px 16px !important; - font-size: 0.82rem !important; - line-height: 1.6 !important; - color: #374151 !important; -} - -.leaflet-popup-tip { - box-shadow: none !important; -} - -.popup-callsign { - font-weight: 700; - font-size: 0.88rem; - color: #1a1a2e; -} - -.popup-detail { - color: #6b7280; - font-size: 0.78rem; -} - -.popup-freq { - font-family: "SF Mono", "Fira Code", monospace; - font-size: 0.78rem; -} diff --git a/frontend/src/components/TowerMap.tsx b/frontend/src/components/TowerMap.tsx deleted file mode 100644 index 4784a89c..00000000 --- a/frontend/src/components/TowerMap.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { useEffect } from "react"; -import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from "react-leaflet"; -import L from "leaflet"; -import "./TowerMap.css"; -import { withCartoKey } from "../utils/basemap"; - -// Fix default icon paths (Leaflet + bundlers issue) -delete L.Icon.Default.prototype._getIconUrl; -L.Icon.Default.mergeOptions({ - iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", - iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", - shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", -}); - -const ICON_COLORS = { - Ideal: "#16a34a", - Good: "#ca8a04", - Far: "#94a3b8", - "Too Close": "#ef4444", -}; - -function makeTowerIcon(distClass, isHighlighted) { - const color = ICON_COLORS[distClass] || "#94a3b8"; - const size = isHighlighted ? 16 : 11; - const border = isHighlighted ? 3 : 2; - const shadow = isHighlighted - ? "0 0 0 3px rgba(59,130,246,.3), 0 2px 6px rgba(0,0,0,.25)" - : "0 1px 4px rgba(0,0,0,.3)"; - return L.divIcon({ - className: "tower-marker", - html: `
`, - iconSize: [size, size], - iconAnchor: [size / 2, size / 2], - }); -} - -const userIcon = L.divIcon({ - className: "user-marker", - html: `
`, - iconSize: [16, 16], - iconAnchor: [8, 8], -}); - -function FitBounds({ towers, userLocation }) { - const map = useMap(); - - useEffect(() => { - if (!userLocation) return; - const points: [number, number][] = [[userLocation.latitude, userLocation.longitude]]; - towers.forEach((t) => points.push([t.latitude, t.longitude])); - - if (points.length > 1) { - map.fitBounds(points, { padding: [50, 50], maxZoom: 13 }); - } else { - map.setView(points[0], 10); - } - }, [towers, userLocation, map]); - - return null; -} - -export default function TowerMap({ towers, userLocation, highlighted }) { - const center: [number, number] = userLocation - ? [userLocation.latitude, userLocation.longitude] - : [39.8, -98.6]; // center of USA (default) - - return ( -
- - - - {userLocation && ( - <> - - - Your Location - - - - - )} - - {towers.map((t) => ( - - - {t.callsign || "Unknown"} -
- {t.name} -
- - {t.latitude}, {t.longitude} - {t.altitude_m != null && ` · ${t.altitude_m} m ASL`} - -
- {t.frequency_mhz} MHz{" "} - ({t.band}) -
- - {t.distance_km} km {t.bearing_cardinal} · {t.received_power_dbm} dBm - -
- - {t.distance_class} - -
-
- ))} - - -
-
- ); -} diff --git a/frontend/src/map-surface.css b/frontend/src/map-surface.css index 5e02d3d3..9cb19cae 100644 --- a/frontend/src/map-surface.css +++ b/frontend/src/map-surface.css @@ -1,9 +1,7 @@ /** * Map surface theme — dash.retina.fm's design system, applied to the live map. * - * Everything is scoped to `.app.map-surface`, a class App only sets on map - * hostnames (see utils/domains.ts). Tower Finder shares this bundle and keeps - * its own look untouched. + * Everything is scoped to `.app.map-surface`, the class App sets on the root. * * The names are dash's (dashboard/src/App.css), and the light block's values * are too. Duplicated rather than shared because the two apps build separately, diff --git a/frontend/src/test/App.test.tsx b/frontend/src/test/App.test.tsx index f98ad905..99ebb7df 100644 --- a/frontend/src/test/App.test.tsx +++ b/frontend/src/test/App.test.tsx @@ -5,12 +5,11 @@ import App from "../App"; describe("App", () => { it("renders the header", () => { render(); - expect(screen.getByText("Tower Finder")).toBeInTheDocument(); + expect(screen.getByText("RETINA")).toBeInTheDocument(); }); - it("shows the search form by default", () => { + it("opens on the live radar tab", () => { render(); - // The search tab should be active, showing the search form - expect(document.querySelector(".app")).toBeTruthy(); + expect(document.querySelector(".app-body.live-active")).toBeTruthy(); }); }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 78afd3d3..101bd7ce 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4,27 +4,6 @@ import type { ArcBufferEntry } from "./components/map/arcBuffer"; -/** Single tower returned by /api/towers */ -export interface Tower { - callsign: string | null; - frequency_mhz: number; - frequency_matched: boolean; - band: string; - distance_km: number; - distance_class: string; -} - -/** /api/towers response */ -export interface TowerSearchResponse { - towers: Tower[]; - query: { lat: number; lon: number }; -} - -/** /api/elevation response */ -export interface ElevationResponse { - elevation_m: number; -} - /* ---- Aircraft / live feed ---- */ export interface Aircraft { diff --git a/frontend/src/utils/domains.ts b/frontend/src/utils/domains.ts index c9459073..80b3a5da 100644 --- a/frontend/src/utils/domains.ts +++ b/frontend/src/utils/domains.ts @@ -5,7 +5,7 @@ * only by subdomain. Each predicate here captures one concrete decision: * * isMapDomain: any "map" surface, on any environment. Used to - * default to the Live Radar tab and hide tower search. + * derive the public-demo predicates below. * usesRealOnlyFeed: hits /ws/aircraft/live so the synthetic fleet never * appears, even if a node leaks through a bad filter. * defaultsGroundTruthOff: ADS-B ground truth starts hidden.