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:
+6
-2
@@ -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.",
|
"\"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.",
|
"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 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": [
|
"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 s’accorde à 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 l’antenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam n’ont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et s’explique maintenant : c’est la position de rangement, et le prochain accord les fait ressortir tout seuls.",
|
"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 s’accorde à 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 l’antenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam n’ont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et s’explique maintenant : c’est 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 s’affiche 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, c’est demander.",
|
"« 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 s’affiche 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, c’est 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 d’entre eux, plus rien ne confirmait que le pylône tournait encore et le bouton s’éteignait puis se rallumait tout du long. L’entré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 qu’une progression continue dans ce sens : un degré du même côté n’est pas une rafale quand le rotor est déjà sous tension.",
|
"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 d’entre eux, plus rien ne confirmait que le pylône tournait encore et le bouton s’éteignait puis se rallumait tout du long. L’entré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 qu’une progression continue dans ce sens : un degré du même côté n’est 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 d’AMSAT. AO-123 reçoit le ton de 67,0 Hz qu’exige sa montée, SO-50 indique dans son libellé que 74,4 Hz arme le minuteur avant que 67,0 puisse l’ouvrir, 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.",
|
"Le plan de fréquences est vérifié contre les listes FM et image d’AMSAT. AO-123 reçoit le ton de 67,0 Hz qu’exige sa montée, SO-50 indique dans son libellé que 74,4 Hz arme le minuteur avant que 67,0 puisse l’ouvrir, 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 m’entend » 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 qu’on 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 n’envoie rien d’autre, et cela fonctionne même avec la veille d’ouverture 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 m’entend » 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 qu’on 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 n’envoie rien d’autre, et cela fonctionne même avec la veille d’ouverture 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 m’entend » n’affiche 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 s’estompe avec l’âge : c’est 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 d’une carte du monde, masquant l’Atlantique 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 m’entend » passent à droite."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
// One station reporting our own transmissions, from PSK Reporter.
|
||||||
type Heard = { call: string; grid: string; band: string; mode: string; snr: number; at: string };
|
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
|
// It started with an arc per station, like the decodes, and that was wrong:
|
||||||
// palette: solid is what this receiver heard, dashed is what someone else
|
// with a few dozen receivers reporting, the map became a fan of lines out of
|
||||||
// heard of us. Colour alone would not do it — the decode arcs already use
|
// one square that buried the very arcs it sat beside. Nothing was gained by
|
||||||
// fourteen of them, and a fifteenth would just look like another band.
|
// 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_COLOUR = '#22d3ee';
|
||||||
const HEARD_DASH = '5 4';
|
|
||||||
|
|
||||||
const MAX_ARCS = 300;
|
const MAX_ARCS = 300;
|
||||||
const MAX_AGE_MS = 30 * 60_000;
|
const MAX_AGE_MS = 30 * 60_000;
|
||||||
@@ -267,36 +273,42 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
}
|
}
|
||||||
}, [decodes, myGrid, colour]);
|
}, [decodes, myGrid, colour]);
|
||||||
|
|
||||||
// The reverse layer. Drawn from OUR square outwards to each station that
|
// The reverse layer: one mark where each station that reported us sits, and
|
||||||
// reported us, dashed, so the direction of the claim is legible: a solid arc
|
// no line to it.
|
||||||
// is something this receiver decoded, a dashed one is somebody else
|
//
|
||||||
// decoding us.
|
// 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(() => {
|
useEffect(() => {
|
||||||
const layer = heardLayerRef.current;
|
const layer = heardLayerRef.current;
|
||||||
if (!layer) return;
|
if (!layer) return;
|
||||||
layer.clearLayers();
|
layer.clearLayers();
|
||||||
const from = gridToLatLon(myGrid);
|
if (!hearMe) return;
|
||||||
if (!from || !hearMe) return;
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const h of heard) {
|
for (const h of heard) {
|
||||||
const to = gridToLatLon(h.grid);
|
const to = gridToLatLon(h.grid);
|
||||||
if (!to) continue;
|
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 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));
|
// The box is bigger than the diamond so there is something to point at:
|
||||||
L.polyline(pts as L.LatLngExpression[][], {
|
// a nine-pixel mark is a fine sight and a poor target.
|
||||||
color: HEARD_COLOUR, weight: 1.2, opacity: 0.7, dashArray: HEARD_DASH, smoothFactor: 0,
|
const icon = L.divIcon({
|
||||||
}).addTo(layer);
|
className: '',
|
||||||
// A ring, not a disc: the decode dots are filled, and a receiver of ours
|
iconSize: [16, 16],
|
||||||
// is a different kind of thing in the same place on the map.
|
iconAnchor: [8, 8],
|
||||||
L.circleMarker([to.lat, to.lon], {
|
html: `<div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center">`
|
||||||
radius: 4, color: HEARD_COLOUR, weight: 1.6, fillOpacity: 0,
|
+ `<div style="width:8px;height:8px;transform:rotate(45deg);border:1.6px solid ${HEARD_COLOUR};opacity:${fade.toFixed(2)}"></div></div>`,
|
||||||
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
});
|
||||||
L.circleMarker([to.lat, to.lon], {
|
L.marker([to.lat, to.lon], { icon, interactive: true, keyboard: false })
|
||||||
radius: 9, opacity: 0, fillOpacity: 0, interactive: true,
|
.bindTooltip(label, { direction: 'top' })
|
||||||
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
.addTo(layer);
|
||||||
}
|
}
|
||||||
}, [heard, hearMe, myGrid]);
|
}, [heard, hearMe]);
|
||||||
|
|
||||||
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
||||||
return (
|
return (
|
||||||
@@ -305,8 +317,11 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
// keeps all of it inside this panel.
|
// keeps all of it inside this panel.
|
||||||
<div className="relative isolate z-0 h-full w-full min-h-0">
|
<div className="relative isolate z-0 h-full w-full min-h-0">
|
||||||
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
||||||
{/* Basemap picker, MainMap's own vocabulary. */}
|
{/* 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">
|
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) => (
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||||
<button key={k} type="button" onClick={() => setBasemap(k)}
|
<button key={k} type="button" onClick={() => setBasemap(k)}
|
||||||
className={cn('px-2 py-0.5 rounded text-[11px]',
|
className={cn('px-2 py-0.5 rounded text-[11px]',
|
||||||
@@ -314,10 +329,15 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
{BASEMAPS[k].label}
|
{BASEMAPS[k].label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{/* Beside the basemap because the two are one decision: the palette
|
</div>
|
||||||
that reads well on the plain map is the one that vanishes over
|
{/* The two things that are not the basemap, on the OTHER side.
|
||||||
the imagery. Written straight through — there is no Save here. */}
|
|
||||||
<span className="mx-0.5 w-px self-stretch bg-border" />
|
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
|
{/* Opens on the crimson the home marker already uses — chosen to hold
|
||||||
up on every basemap, which is a better first suggestion than
|
up on every basemap, which is a better first suggestion than
|
||||||
whichever band colour happens to be first in the palette. */}
|
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>}
|
{hearMe && <span className="tabular-nums opacity-80">{heard.length}</span>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* What the dashed arcs are. In the legend rather than a tooltip because
|
{/* What the diamonds 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
|
they are the only thing on the map that is not a decode of ours, and
|
||||||
unexplained second style is worse than none. */}
|
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 && (
|
{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]">
|
<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 })}
|
{t('ftmap.hearMeLegend', { n: heard.length })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user