feat(ftmap): one colour for every decode, over the band palette
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/.
This commit is contained in:
@@ -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<string, string> = {
|
||||
};
|
||||
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}
|
||||
</button>
|
||||
))}
|
||||
{/* 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. */}
|
||||
<span className="mx-0.5 w-px self-stretch bg-border" />
|
||||
{/* 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. */}
|
||||
<input type="color" title={t('ftmap.colour')}
|
||||
className="size-5 self-center rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||
value={colour || '#e11d48'}
|
||||
onChange={(e) => { const v = asHex(e.target.value); setColour(v); writeUiPref(COL_KEY, v); }} />
|
||||
{!!colour && (
|
||||
<button type="button" title={t('ftmap.colourPerBand')}
|
||||
onClick={() => { setColour(''); writeUiPref(COL_KEY, ''); }}
|
||||
className="px-1 text-[11px] text-muted-foreground hover:text-foreground">↺</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 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 && (
|
||||
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
|
||||
{bands.map((b) => (
|
||||
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
|
||||
<span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />
|
||||
{!colour && <span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />}
|
||||
{b.toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user