fix(ftmap): who-hears-me as marks, and controls off the map

Two things the first version got wrong.

The arcs. One line per reporting station made a fan out of a single
square, and with a few dozen receivers it buried the decode arcs it was
drawn beside. They earned nothing either: an arc's job on the decode
layer is to say WHICH station a path belongs to, and here every path
starts in the same place. So marks only, and the mark is a hollow
diamond faded by age over the window. Shape rather than colour, because
the decode dots are filled circles and the arcs already use fourteen
colours — a fifteenth reads as another band. A divIcon, canvas being
able to draw circles and nothing else; affordable here at a few dozen
nodes where it would not be against three hundred arcs.

The controls. The basemap row was at left-12, which is 48 px, against a
zoom control that is 30 px of buttons plus its 10 px margin and a
border — so it overlapped, and the minus button took clicks meant for
Street. Then the colour input and the reverse-layer switch were added to
the same row, which grew until it reached the middle of a world map:
covering the Atlantic to spare a top-right corner that was empty
throughout, Leaflet putting nothing there but an attribution that lives
at the bottom. Basemaps stay left at left-16, the other two move right.
This commit is contained in:
2026-09-10 14:42:26 +02:00
parent d25edd114c
commit eb06cc29b0
2 changed files with 65 additions and 38 deletions
+59 -36
View File
@@ -61,14 +61,20 @@ const asHex = (v: string) => (/^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : '');
// One station reporting our own transmissions, from PSK Reporter.
type Heard = { call: string; grid: string; band: string; mode: string; snr: number; at: string };
// The reverse layer is DASHED, and in one colour whatever the band.
// The reverse layer is marks only, in one colour whatever the band, and the
// mark is a DIAMOND.
//
// The two layers answer opposite questions and must not be readable as one
// palette: solid is what this receiver heard, dashed is what someone else
// heard of us. Colour alone would not do it — the decode arcs already use
// fourteen of them, and a fifteenth would just look like another band.
// It started with an arc per station, like the decodes, and that was wrong:
// with a few dozen receivers reporting, the map became a fan of lines out of
// one square that buried the very arcs it sat beside. Nothing was gained by
// them either — an arc's job on the decode layer is to say WHICH of many
// stations a path belongs to, and here every path starts at the same place.
//
// Shape, then, rather than colour, carries the distinction: the decode dots
// are small filled circles, and the arcs already use fourteen colours, so a
// fifteenth would read as another band. A hollow diamond is unmistakably not
// one of them at a glance.
const HEARD_COLOUR = '#22d3ee';
const HEARD_DASH = '5 4';
const MAX_ARCS = 300;
const MAX_AGE_MS = 30 * 60_000;
@@ -267,36 +273,42 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
}
}, [decodes, myGrid, colour]);
// The reverse layer. Drawn from OUR square outwards to each station that
// reported us, dashed, so the direction of the claim is legible: a solid arc
// is something this receiver decoded, a dashed one is somebody else
// decoding us.
// The reverse layer: one mark where each station that reported us sits, and
// no line to it.
//
// A divIcon rather than a canvas circle, because canvas draws circles and
// nothing else, and the whole point is a shape that is not a circle. The
// cost is DOM nodes, which is affordable HERE and would not be on the decode
// layer: this is a few dozen receivers against three hundred arcs.
useEffect(() => {
const layer = heardLayerRef.current;
if (!layer) return;
layer.clearLayers();
const from = gridToLatLon(myGrid);
if (!from || !hearMe) return;
if (!hearMe) return;
const now = Date.now();
for (const h of heard) {
const to = gridToLatLon(h.grid);
if (!to) continue;
const ageMin = Math.max(0, Math.round((now - Date.parse(h.at)) / 60_000));
const ageMs = Math.max(0, now - Date.parse(h.at));
const ageMin = Math.round(ageMs / 60_000);
// Faded with age over the window, as the decode arcs are: the freshest
// report is the one that says a path is open NOW.
const fade = Math.max(0.3, 1 - ageMs / (15 * 60_000));
const label = `${h.call} · ${h.grid} · ${h.snr > 0 ? '+' : ''}${h.snr} dB · ${h.band}${ageMin > 0 ? ` · ${ageMin}'` : ''}`;
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
L.polyline(pts as L.LatLngExpression[][], {
color: HEARD_COLOUR, weight: 1.2, opacity: 0.7, dashArray: HEARD_DASH, smoothFactor: 0,
}).addTo(layer);
// A ring, not a disc: the decode dots are filled, and a receiver of ours
// is a different kind of thing in the same place on the map.
L.circleMarker([to.lat, to.lon], {
radius: 4, color: HEARD_COLOUR, weight: 1.6, fillOpacity: 0,
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
L.circleMarker([to.lat, to.lon], {
radius: 9, opacity: 0, fillOpacity: 0, interactive: true,
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
// The box is bigger than the diamond so there is something to point at:
// a nine-pixel mark is a fine sight and a poor target.
const icon = L.divIcon({
className: '',
iconSize: [16, 16],
iconAnchor: [8, 8],
html: `<div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center">`
+ `<div style="width:8px;height:8px;transform:rotate(45deg);border:1.6px solid ${HEARD_COLOUR};opacity:${fade.toFixed(2)}"></div></div>`,
});
L.marker([to.lat, to.lon], { icon, interactive: true, keyboard: false })
.bindTooltip(label, { direction: 'top' })
.addTo(layer);
}
}, [heard, hearMe, myGrid]);
}, [heard, hearMe]);
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
return (
@@ -305,8 +317,11 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
// keeps all of it inside this panel.
<div className="relative isolate z-0 h-full w-full min-h-0">
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
{/* Basemap picker, MainMap's own vocabulary. */}
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
{/* Basemap picker, MainMap's own vocabulary.
left-16, not left-12: Leaflet's zoom control is 30 px of buttons plus
its 10 px margin and a border, and at 48 px this row started on top
of it — the button took the click that was meant for Street. */}
<div className="absolute top-2 left-16 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
<button key={k} type="button" onClick={() => setBasemap(k)}
className={cn('px-2 py-0.5 rounded text-[11px]',
@@ -314,10 +329,15 @@ 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" />
</div>
{/* The two things that are not the basemap, on the OTHER side.
They sat in the same row, which grew until it reached the middle of
the map — and a control bar spanning half the width of a world map is
covering the Atlantic to save a corner that was empty the whole time.
Leaflet puts nothing top-right but the attribution, which is at the
bottom. */}
<div className="absolute top-2 right-2 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-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. */}
@@ -343,12 +363,15 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
{hearMe && <span className="tabular-nums opacity-80">{heard.length}</span>}
</button>
</div>
{/* What the dashed arcs are. In the legend rather than a tooltip because
it is the only thing on the map that is not a decode of ours, and an
unexplained second style is worse than none. */}
{/* What the diamonds are. In the legend rather than a tooltip because
they are the only thing on the map that is not a decode of ours, and
an unexplained second mark is worse than none.
Bottom-right, where the band legend is not: the two would otherwise
stack into one block and read as one key. */}
{hearMe && heard.length > 0 && (
<div className="absolute bottom-2 right-2 z-[1000] flex items-center gap-1.5 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border text-[11px]">
<span className="inline-block w-4 border-t-2 border-dashed" style={{ borderColor: HEARD_COLOUR }} />
<span className="inline-block size-2 rotate-45 border-[1.6px]" style={{ borderColor: HEARD_COLOUR }} />
{t('ftmap.hearMeLegend', { n: heard.length })}
</div>
)}