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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user