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
+6 -2
View File
@@ -17,7 +17,9 @@
"\"Later\" on an update notice now asks for how long: 1, 4, 12 or 24 hours. It only hid the card before, and the check behind it runs every five minutes, so the same notice came back four times an hour all evening for a version already declined. The delay is recorded against that version, so a newer release is different news and appears at once — a deferral can never bury an update. The cross hides it for an hour, and opening About still answers whatever was deferred, because asking is asking.",
"Stop stays lit for the whole rotation instead of blinking. Keeping the wind out took a four-degree step, but four degrees is four seconds of travel on a real rotor, so between two of them nothing confirmed the mast was still turning and the button went dark and came back the whole way round. Getting in stays strict — two steps the same way, which weather never gives — while staying in now needs only continued progress in that direction: one degree the same way is not a gust when the rotor is already under power.",
"The frequency plan is checked against AMSAT's live FM and image lists. AO-123 gets the 67.0 Hz tone its uplink needs, SO-50 says in its label that 74.4 Hz arms the timer before 67.0 can key it, and eight SSTV satellites are added — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S and RS83S — as receive-only entries, with SSTV downlinks added to SONATE-2 and QMR-KWT-2. Every FM bird's uplink, downlink and tone now agrees with the published table.",
"The FT map can now show who hears YOU. \"Who hears me\" draws, in dashed cyan, every station reporting your own transmissions to PSK Reporter, alongside the solid arcs of what you decode — the direction you cannot see from your own receiver, and on FT8 the one that says whether calling is worth the cycle. It costs almost nothing: your callsign goes in the topic's transmit level, so the broker sends nothing else, and it works with the band-opening watch switched off. Off until asked for, and each station appears once with its freshest report inside a fifteen-minute window."
"The FT map can now show who hears YOU. \"Who hears me\" draws, in dashed cyan, every station reporting your own transmissions to PSK Reporter, alongside the solid arcs of what you decode — the direction you cannot see from your own receiver, and on FT8 the one that says whether calling is worth the cycle. It costs almost nothing: your callsign goes in the topic's transmit level, so the broker sends nothing else, and it works with the band-opening watch switched off. Off until asked for, and each station appears once with its freshest report inside a fifteen-minute window.",
"\"Who hears me\" is marks alone, no arcs. With a few dozen receivers reporting, a line to each made a fan out of one square that buried the decode arcs beside it — and the lines said nothing, since every one of these paths starts in the same place. The mark is a hollow diamond, fading with age: shape rather than colour carries the difference, the decode dots being filled circles and the arcs having used fourteen colours already.",
"The FT map's controls stop covering it. The basemap buttons had crept over Leaflet's zoom control, so the minus button took clicks meant for Street, and the row had grown with the colour picker and the reverse layer until it reached the middle of a world map — covering the Atlantic to save a top-right corner that was empty the whole time. Basemaps stay on the left, clear of the zoom; the colour and \"Who hears me\" move to the right."
],
"fr": [
"SteppIR : un bouton Calibrer, à côté de Rétracter. Il amène chaque élément en butée pour que le contrôleur retrouve son zéro — le remède à une antenne qui saccorde à la mauvaise longueur après une coupure en pleine course, après avoir poussé les éléments à la main, ou après un moteur qui a glissé. Cela prend plusieurs minutes et lantenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam nont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et sexplique maintenant : cest la position de rangement, et le prochain accord les fait ressortir tout seuls.",
@@ -34,7 +36,9 @@
"« Plus tard » sur une notification de mise à jour demande désormais combien de temps : 1, 4, 12 ou 24 heures. Le bouton ne faisait que masquer la fenêtre, et la vérification derrière tourne toutes les cinq minutes : la même notification revenait quatre fois par heure toute la soirée pour une version déjà refusée. Le report est enregistré pour cette version précise, donc une version plus récente est une autre nouvelle et saffiche immédiatement — un report ne peut jamais enterrer une mise à jour. La croix masque pendant une heure, et ouvrir « À propos » répond toujours, même sur une version reportée : demander, cest demander.",
"Le bouton Stop reste allumé pendant toute la rotation au lieu de clignoter. Écarter le vent demandait un écart de quatre degrés, mais quatre degrés représentent quatre secondes de course sur un vrai rotor : entre deux dentre eux, plus rien ne confirmait que le pylône tournait encore et le bouton s’éteignait puis se rallumait tout du long. Lentrée reste stricte — deux écarts dans le même sens, ce que la météo ne produit jamais — tandis que le maintien ne demande plus quune progression continue dans ce sens : un degré du même côté nest pas une rafale quand le rotor est déjà sous tension.",
"Le plan de fréquences est vérifié contre les listes FM et image dAMSAT. AO-123 reçoit le ton de 67,0 Hz quexige sa montée, SO-50 indique dans son libellé que 74,4 Hz arme le minuteur avant que 67,0 puisse louvrir, et huit satellites SSTV sont ajoutés — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S et RS83S — en réception seule, avec une descente SSTV ajoutée à SONATE-2 et QMR-KWT-2. La montée, la descente et le ton de chaque satellite FM correspondent désormais à la table publiée.",
"La carte FT peut désormais montrer qui VOUS entend. « Qui mentend » trace en pointillés cyan chaque station qui rapporte vos propres émissions à PSK Reporter, à côté des arcs pleins de ce que vous décodez — la direction quon ne peut pas voir depuis son propre récepteur, et en FT8 celle qui dit si appeler vaut le cycle. Le coût est quasi nul : votre indicatif est placé dans le niveau émission du topic, donc le broker nenvoie rien dautre, et cela fonctionne même avec la veille douverture de bande éteinte. Éteint par défaut, et chaque station apparaît une fois avec son rapport le plus récent dans une fenêtre de quinze minutes."
"La carte FT peut désormais montrer qui VOUS entend. « Qui mentend » trace en pointillés cyan chaque station qui rapporte vos propres émissions à PSK Reporter, à côté des arcs pleins de ce que vous décodez — la direction quon ne peut pas voir depuis son propre récepteur, et en FT8 celle qui dit si appeler vaut le cycle. Le coût est quasi nul : votre indicatif est placé dans le niveau émission du topic, donc le broker nenvoie rien dautre, et cela fonctionne même avec la veille douverture de bande éteinte. Éteint par défaut, et chaque station apparaît une fois avec son rapport le plus récent dans une fenêtre de quinze minutes.",
"« Qui mentend » naffiche plus que des marques, sans arcs. Avec quelques dizaines de récepteurs, un trait vers chacun formait un éventail depuis un seul carré qui enterrait les arcs de décodage voisins — et ces traits ne disaient rien, puisque tous ces chemins partent du même endroit. La marque est un losange creux qui sestompe avec l’âge : cest la forme, et non la couleur, qui porte la différence, les points de décodage étant des cercles pleins et les arcs utilisant déjà quatorze couleurs.",
"Les contrôles de la carte FT cessent de la recouvrir. Les boutons de fond de carte avaient débordé sur le zoom de Leaflet — le bouton moins prenait les clics destinés à Street — et la rangée s’était allongée avec le sélecteur de couleur et la couche inverse jusqu’à atteindre le milieu dune carte du monde, masquant lAtlantique pour épargner un coin haut-droit resté vide tout ce temps. Les fonds de carte restent à gauche, dégagés du zoom ; la couleur et « Qui mentend » passent à droite."
]
},
{
+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>
)}