From a20362fac6b5025e1a23afb1a3a71277ff505e9c Mon Sep 17 00:00:00 2001 From: Babissimo Date: Tue, 15 Sep 2026 15:09:44 +0100 Subject: [PATCH] Retire this repo's tower search UI, now the service serves it tower-finder-service has owned the tower API since the monolith's copy was deleted, and it now owns the UI too: it builds its own SPA and serves it from its own edge. On 2026-09-14 the Cloudflare origin rules moved towers.retina.fm, its staging and test counterparts and the fleet's tower-finder.retina.fm onto port 8443, so all four names reach the service and none of them reach us. That leaves the copy here dead rather than merely redundant. frontend/dist is served on HOST_MAIN, HOST_MAP and HOST_TESTMAP only, and isMapDomain already hid the tower tab on the latter two, so with HOST_MAIN flipped away the tab was reachable on no live surface. The service's copy had also moved well ahead of it, gaining address geocoding, rank tiers and a working frequencies parameter, so keeping ours invited exactly the split-brain that 86capx9mv was raised to end. Every surface this bundle still serves is a live map, so the hostname-dependent header goes with it and App renders one surface unconditionally. That also settles, by deletion, the "Tower Finder" display label left open during the rename: it named a surface that no longer ships from here. Because .map-surface is now always set, the dark physics-tab background it used to override was dead too, and collapses into the light rule. Nothing about nginx changes here. Every vhost keeps forwarding /api/towers, /api/elevation and /api/config to the service exactly as before, including the ${HOST_LEGACY_REDIRECT} vhost that carries the fleet's name. Retiring that vhost is a separate change, deliberately held back: while it exists, deleting the Cloudflare origin rule is still a working rollback for the fleet. The entry chunks every map surface loads drop from 56.66 to 43.84 kB of JS (gzip 18.04 to 14.08) and 26.45 to 19.23 kB of CSS (gzip 5.67 to 4.13). Co-Authored-By: Claude Opus 5 --- ONBOARDING.md | 12 +- README.md | 24 +- docs/architecture.md | 6 +- frontend/e2e/live-map.spec.ts | 4 +- frontend/index.html | 2 +- frontend/src/App.css | 147 +----------- frontend/src/App.tsx | 141 ++---------- frontend/src/api.ts | 30 --- frontend/src/components/PhysicsSettings.css | 4 +- frontend/src/components/ResultsTable.css | 114 ---------- frontend/src/components/ResultsTable.tsx | 111 --------- frontend/src/components/SearchForm.css | 239 -------------------- frontend/src/components/SearchForm.tsx | 212 ----------------- frontend/src/components/TowerMap.css | 52 ----- frontend/src/components/TowerMap.tsx | 150 ------------ frontend/src/map-surface.css | 4 +- frontend/src/test/App.test.tsx | 7 +- frontend/src/types.ts | 21 -- frontend/src/utils/domains.ts | 2 +- 19 files changed, 49 insertions(+), 1233 deletions(-) delete mode 100644 frontend/src/components/ResultsTable.css delete mode 100644 frontend/src/components/ResultsTable.tsx delete mode 100644 frontend/src/components/SearchForm.css delete mode 100644 frontend/src/components/SearchForm.tsx delete mode 100644 frontend/src/components/TowerMap.css delete mode 100644 frontend/src/components/TowerMap.tsx 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.