From f998023a28fa35d213a46ce6bfdbf6fe2480942f Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 10 Sep 2026 13:43:06 +0200 Subject: [PATCH] feat(ftmap): one colour for every decode, over the band palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The band palette is calibrated against a plain basemap. Over the Esri imagery the 60m navy and the 70cm olive all but disappear, and 20m yellow goes over the deserts — and the palette only earns its keep for somebody watching several bands at once, which most operators are not. So a colour input beside the basemap buttons, where it belongs: the two are one decision, since the palette that reads well on the plain map is the one that vanishes over the ground. Empty means per band, which stays the default, and the reset arrow appears only once something is set. It opens on the crimson the home marker already uses, that being chosen to hold up on any basemap. With a colour forced the legend keeps the band NAMES and drops the swatches: which bands are up is still worth knowing, a colour key that maps to nothing is not. Stored as a portable UI pref, like the grid map's own fills, so it travels with data/. --- frontend/src/components/FTMapPanel.tsx | 47 ++++++++++++++++++++++---- frontend/src/lib/uiPref.ts | 1 + 2 files changed, 42 insertions(+), 6 deletions(-) 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