diff --git a/frontend/src/components/FTMapPanel.tsx b/frontend/src/components/FTMapPanel.tsx index a3a6644..f1ebcda 100644 --- a/frontend/src/components/FTMapPanel.tsx +++ b/frontend/src/components/FTMapPanel.tsx @@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView'; import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase'; +import { writeUiPref } from '@/lib/uiPref'; // FT Map — the live decode feed as geography: every station decoded in the // last half hour, an arc from the operator's own square to theirs, coloured by @@ -41,6 +42,20 @@ const BAND_COLOURS: Record = { }; const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af'; +// One colour for every arc, overriding the palette. Empty means per band, +// which stays the default. +// +// The palette is only useful to somebody watching several bands at once, and +// it is calibrated against a plain map: 60m navy and 70cm olive all but +// disappear over the satellite imagery, and 20m yellow over the deserts. An +// operator on one band has nothing to lose by painting the whole map in a +// colour that shows up against the ground he chose. +const COL_KEY = 'opslog.ftMapColour'; + +// A colour input only accepts #rrggbb, so anything else stored here is +// treated as no choice at all rather than driving the swatch to black. +const asHex = (v: string) => (/^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : ''); + const MAX_ARCS = 300; const MAX_AGE_MS = 30 * 60_000; @@ -53,6 +68,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: { }) { // Held in refs so the redraw below does not have to list them as dependencies // and rebuild every arc whenever the parent re-renders. + const [colour, setColour] = useState(() => asHex(localStorage.getItem(COL_KEY) ?? '')); const selectRef = useRef(onSelect); const callRef = useRef(onCall); useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]); @@ -156,21 +172,21 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: { if (!to) continue; const age = now - Date.parse(d.at); const fade = Math.max(0.15, 1 - age / MAX_AGE_MS); - const colour = bandColour(d.band); + const stroke = colour || bandColour(d.band); // Cut at the antimeridian: this map shows ONE world, so a path running // past ±180 has to leave one edge and come back at the other. Without it // every arc out of VK or ZL was drawn into the blank space off the side // of the map, its far end sitting alone on the opposite coast. const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48)); L.polyline(pts as L.LatLngExpression[][], { - color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0, + color: stroke, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0, }).addTo(layer); const label = `${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`; const mk = L.circleMarker([to.lat, to.lon], { // A three-pixel dot is a fine mark and a poor target, so the visible // radius stays and an invisible one three times the size takes the // clicks. - radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade, + radius: 3, color: stroke, weight: 1, fillColor: stroke, fillOpacity: 0.9 * fade, }).bindTooltip(label, { direction: 'top' }).addTo(layer); // The tooltip goes on the HIT circle too, and it is the one that matters: // being on top, it takes the hover as well as the click, and binding it @@ -196,7 +212,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: { }); } } - }, [decodes, myGrid]); + }, [decodes, myGrid, colour]); const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))]; return ( @@ -214,13 +230,32 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: { {BASEMAPS[k].label} ))} + {/* Beside the basemap because the two are one decision: the palette + that reads well on the plain map is the one that vanishes over + the imagery. Written straight through — there is no Save here. */} + + {/* Opens on the crimson the home marker already uses — chosen to hold + up on every basemap, which is a better first suggestion than + whichever band colour happens to be first in the palette. */} + { const v = asHex(e.target.value); setColour(v); writeUiPref(COL_KEY, v); }} /> + {!!colour && ( + + )} - {/* Band legend — only the bands actually on screen. */} + {/* Band legend — only the bands actually on screen. With one colour + forced it keeps the band NAMES and drops the swatches: which bands + are up is still worth knowing, a colour key that no longer maps to + anything is not. */} {bands.length > 0 && (
{bands.map((b) => ( - + {!colour && } {b.toUpperCase()} ))} diff --git a/frontend/src/lib/uiPref.ts b/frontend/src/lib/uiPref.ts index 2c657cf..4ce31c5 100644 --- a/frontend/src/lib/uiPref.ts +++ b/frontend/src/lib/uiPref.ts @@ -69,6 +69,7 @@ const PORTABLE_KEYS = [ 'opslog.clusterHideSpots', // cluster console: hide the DX spot flood so replies are readable 'opslog.clusterConsoleFollow', // cluster console: keep the view pinned to the newest line 'opslog.gridMapColorConfirmed', 'opslog.gridMapColorWorked', // grid map: chosen fills (empty = follow the theme) + 'opslog.ftMapColour', // FT map: one colour for every arc (empty = the band palette) // NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here. // They are handled by lib/gridPrefs, which scopes the localStorage cache PER // PROFILE and mirrors to the DB (already per-profile) itself — mirroring them