feat(grids): one world, a zoom that holds it, and the modes to filter it
The grid-square map drew the world two or three times across a wide window, each copy carrying the same squares — worldCopyJump earns its keep on the great-circle map, where a path reads better crossing a second Asia, and costs only confusion on a map of where you have been heard. It is off here, the tiles no longer wrap, and addBasemap takes the option so the other map is untouched. Zooming out far enough to hold Greenland and Antarctica together was impossible: the floor was zoom 1, and one step further out was already too far. Zoom 0 is the whole planet, and the view now opens on a fitWorld computed from the container rather than a guessed zoom level — repeated once when the container first has a real size, because the tab is mounted hidden. That exposed the next thing: Esri answers a request outside the world with a grey "Map data not yet available" tile, and zoomed out that was most of the screen. The tile layers carry a bounds, so those tiles are never asked for, and what is left beside the planet is the panel's own background instead of a white slab. Finally the mode filter offers All, Phone and CW beside Digital and FTx. The backend already accepted all of them — GridSquares understands "ALL" and the ModeClass names — only the interface was offering two.
This commit is contained in:
@@ -34,6 +34,10 @@ type Square = { grid: string; count: number; confirmed: boolean; band?: string;
|
||||
// shape is silently not drawn — a map with a correct square count and nothing
|
||||
// on it. So the token is RESOLVED to its literal value here, once per theme,
|
||||
// which is also why every other map in this app passes hex.
|
||||
// The Earth, in the projection's own terms. Latitude stops at ±85.0511 because
|
||||
// that is where Web Mercator does.
|
||||
const WORLD_BOUNDS = L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180));
|
||||
|
||||
function cssColour(token: string, fallback: string): string {
|
||||
try {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(token).trim();
|
||||
@@ -41,14 +45,14 @@ function cssColour(token: string, fallback: string): string {
|
||||
} catch { return fallback; }
|
||||
}
|
||||
|
||||
// Mode scope. Two, because the map lives beside a panel that decodes FTx: CW,
|
||||
// phone and "everything" answer a question nobody is asking here, and a row of
|
||||
// choices that are never the right one is just noise to read past.
|
||||
//
|
||||
// Digital is every digital mode; FTx is the FT family alone, which is the
|
||||
// narrower and usually the honest one — a square worked on RTTY in a contest is
|
||||
// Mode scope. The names come straight from the backend's own classes ("ALL",
|
||||
// "PHONE", "CW", "DIGI") plus FTX, which is narrower than digital and usually
|
||||
// the honest one beside an FTx panel — a square worked on RTTY in a contest is
|
||||
// not a square worked on FT8.
|
||||
const SCOPES = [
|
||||
{ key: 'ALL', label: 'gsm.all' },
|
||||
{ key: 'PHONE', label: 'gsm.phone' },
|
||||
{ key: 'CW', label: 'gsm.cw' },
|
||||
{ key: 'DIGI', label: 'gsm.digital' },
|
||||
{ key: 'FTX', label: 'gsm.ftx' },
|
||||
] as const;
|
||||
@@ -115,9 +119,24 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
preferCanvas: true,
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
worldCopyJump: true,
|
||||
minZoom: 1,
|
||||
}).setView([25, 0], 2);
|
||||
// ONE world, not a row of them. worldCopyJump exists to make a path look
|
||||
// continuous across the date line; here it only meant the same continent
|
||||
// appeared two or three times, each carrying the same squares.
|
||||
worldCopyJump: false,
|
||||
// Zoom 0 is the whole planet in 256 pixels — the level at which Greenland
|
||||
// and Antarctica are both on screen. The old floor of 1 made that
|
||||
// impossible on any window wider than it is tall: one step out was already
|
||||
// too far in.
|
||||
minZoom: 0,
|
||||
maxBoundsViscosity: 1, // don't let a drag slide the world off to one side
|
||||
}).setView([20, 0], 2);
|
||||
// The whole world, once, whatever the window is shaped like — computed by
|
||||
// Leaflet from the container rather than guessed with a zoom number.
|
||||
m.fitWorld({ animate: false });
|
||||
// Latitude only: the poles are the edge of the projection and there is
|
||||
// nothing beyond them, while leaving longitude free keeps a drag from
|
||||
// fighting the operator near the date line.
|
||||
m.setMaxBounds(L.latLngBounds(L.latLng(-85, -Infinity), L.latLng(85, Infinity)));
|
||||
mapRef.current = m;
|
||||
layerRef.current = L.layerGroup().addTo(m);
|
||||
// Leaflet measures its container ONCE, when the map is created, and never
|
||||
@@ -126,7 +145,18 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
// rest of the panel stayed blank underneath it, whatever the window size.
|
||||
// Watching the host is the only fix that also survives a window resize, a
|
||||
// split-pane drag and the tab being shown for the first time.
|
||||
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
|
||||
// fitWorld above ran against a container that may still have had no size —
|
||||
// the tab is mounted hidden. Fit ONCE more the first time it really has one,
|
||||
// and never again: after that the view belongs to the operator.
|
||||
let fitted = false;
|
||||
const ro = new ResizeObserver(() => {
|
||||
m.invalidateSize({ animate: false });
|
||||
const el = hostRef.current;
|
||||
if (!fitted && el && el.clientWidth > 0 && el.clientHeight > 0) {
|
||||
fitted = true;
|
||||
m.fitWorld({ animate: false });
|
||||
}
|
||||
});
|
||||
ro.observe(hostRef.current);
|
||||
return () => { ro.disconnect(); m.remove(); mapRef.current = null; layerRef.current = null; };
|
||||
}, []);
|
||||
@@ -138,7 +168,10 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
useEffect(() => {
|
||||
const m = mapRef.current;
|
||||
if (!m) return;
|
||||
addBasemap(m, basemap, baseRef, labelsRef);
|
||||
// noWrap stops the world REPEATING; bounds stops Leaflet asking for the
|
||||
// empty columns either side of it. What is left beside the planet is then
|
||||
// the panel's own background rather than a wall of grey apologies.
|
||||
addBasemap(m, basemap, baseRef, labelsRef, { noWrap: true, bounds: WORLD_BOUNDS });
|
||||
baseRef.current?.bringToBack();
|
||||
}, [basemap]);
|
||||
|
||||
@@ -254,7 +287,10 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
{err && <p className="px-2.5 py-1 text-[11px] text-destructive shrink-0">{err}</p>}
|
||||
{/* The map host must have a real height or Leaflet renders nothing at all
|
||||
— flex-1 + min-h-0, never a percentage. */}
|
||||
<div ref={hostRef} className="flex-1 min-h-0" />
|
||||
{/* The ground beside the planet is the panel's own background: Leaflet
|
||||
paints its container, and left to its default that surround was a
|
||||
bright white slab against a dark theme. */}
|
||||
<div ref={hostRef} className="flex-1 min-h-0 bg-card" />
|
||||
<footer className="flex items-center gap-3 px-2.5 py-1 border-t border-border bg-muted/30 shrink-0 text-[10px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-block size-2.5 rounded-[2px]"
|
||||
|
||||
@@ -85,13 +85,27 @@ export function addBasemap(
|
||||
key: BasemapKey,
|
||||
base: React.MutableRefObject<L.TileLayer | null>,
|
||||
labels: React.MutableRefObject<L.TileLayer | null>,
|
||||
// noWrap draws the world ONCE instead of repeating it east and west.
|
||||
//
|
||||
// The great-circle map wants the repeats: a path from France to Japan reads
|
||||
// better crossing a second copy of Asia than wrapping round the back. A map of
|
||||
// where you have been heard does not — three North Americas is three times the
|
||||
// same answer, and it makes the picture harder to read, not wider.
|
||||
// bounds stops Leaflet asking for tiles outside the world at all: Esri answers
|
||||
// those with a grey "Map data not yet available" image, and zoomed out on a
|
||||
// wide window that is most of the screen.
|
||||
opts?: { noWrap?: boolean; bounds?: L.LatLngBoundsExpression },
|
||||
) {
|
||||
if (base.current) { m.removeLayer(base.current); base.current = null; }
|
||||
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
||||
const bm = BASEMAPS[key];
|
||||
const opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 };
|
||||
base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||
const tileOpts: L.TileLayerOptions = {
|
||||
maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||||
noWrap: !!opts?.noWrap,
|
||||
...(opts?.bounds ? { bounds: opts.bounds } : {}),
|
||||
};
|
||||
base.current = L.tileLayer(bm.url, { ...tileOpts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, tileOpts).addTo(m);
|
||||
}
|
||||
|
||||
function dot(color: string): L.DivIcon {
|
||||
|
||||
@@ -312,7 +312,7 @@ const en: Dict = {
|
||||
'clu.slotHighlightHint': '(by callsign, whatever the entity status says)',
|
||||
'rq.searchPh': 'Search callsign… 4S · *4S · *4S*', 'rq.searchTip': 'A plain word matches the START of a callsign: 4S finds 4S7AB. * is any run of characters and ? is exactly one, so *4S ends with 4S, *4S* contains it anywhere, and F?BPO matches F4BPO.',
|
||||
'gsc.scope': 'Match a square by', 'gsc.hunt': 'Chase', 'gsc.huntNew': 'New — never worked', 'gsc.huntUnconf': 'New and unconfirmed', 'gsc.scope_band_digi': 'This band + any digital mode', 'gsc.scope_band_mode': 'This band + this exact mode', 'gsc.scope_band_ftx': 'This band + any FT mode (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Any band + any digital mode', 'gsc.scope_mix_mode': 'Any band + this exact mode', 'gsc.scope_mix_ftx': 'Any band + any FT mode (FT8/FT4/FT2)', 'gsc.hint': 'Decides when a square stops being NEW. Narrower means more squares to chase: per band and per exact mode is the most demanding, any band and any digital mode the least. Chasing unconfirmed as well keeps a square wanted until a QSL, LoTW or eQSL confirmation arrives — it is still missing from the award until then.',
|
||||
'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed',
|
||||
'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.all': 'All', 'gsm.phone': 'Phone', 'gsm.cw': 'CW', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed',
|
||||
'bo.nearKm': 'Count receivers within', 'bo.nearKmHint': 'A report proves YOUR path only if it was collected near you. Smaller is more local but leaves fewer receivers listening — too small and the watch has nothing to look at. 300 km borrows a whole region; 100 km suits 2 m, where a duct is narrow.',
|
||||
'bo.open': 'open', 'bo.liveTip': '{band} is open — {n} stations, ~{km} km, {sector}{season}. Click for the band map.', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.spotTtl': 'Spot lifetime', 'clu.spotTtlNever': 'Keep', 'clu.spotTtlHint': 'minutes — spots older than this are removed from the list and the band maps. 0 keeps them.', 'clu.chaseGrids': 'Chase new grids', 'clu.chaseGridsHint': '(learns locators from your own WSJT-X decodes AND from PSK Reporter, and keeps them in their own database so the cluster shows them from the first second)', 'clu.chaseGridsStat': '{n} locators known — {p} waiting to be written', 'clu.workedSameSlot': 'Already worked only on the same slot',
|
||||
'clu.macros': 'Command buttons', 'clu.macrosHint': 'A named button beside the cluster command box. Leave the command empty and the button is not shown.',
|
||||
@@ -793,7 +793,7 @@ const fr: Dict = {
|
||||
'clu.slotHighlightHint': "(par indicatif, quel que soit le statut de l'entité)",
|
||||
'rq.searchPh': 'Chercher un indicatif… 4S · *4S · *4S*', 'rq.searchTip': 'Un mot simple correspond au DÉBUT de l’indicatif : 4S trouve 4S7AB. * remplace n’importe quelle suite de caractères et ? exactement un, donc *4S se termine par 4S, *4S* le contient n’importe où, et F?BPO correspond à F4BPO.',
|
||||
'gsc.scope': 'Carré déjà fait selon', 'gsc.hunt': 'Chasser', 'gsc.huntNew': 'Nouveau — jamais contacté', 'gsc.huntUnconf': 'Nouveau et non confirmé', 'gsc.scope_band_digi': 'Cette bande + tout mode numérique', 'gsc.scope_band_mode': 'Cette bande + ce mode exact', 'gsc.scope_band_ftx': 'Cette bande + tout mode FT (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Toutes bandes + tout mode numérique', 'gsc.scope_mix_mode': 'Toutes bandes + ce mode exact', 'gsc.scope_mix_ftx': 'Toutes bandes + tout mode FT (FT8/FT4/FT2)', 'gsc.hint': 'Détermine quand un carré cesse d’être NEW. Plus c’est étroit, plus il y a de carrés à chasser : par bande et par mode exact est le plus exigeant, toutes bandes et tout numérique le moins. Chasser aussi les non confirmés garde un carré recherché jusqu’à une confirmation QSL, LoTW ou eQSL — il manque toujours au diplôme d’ici là.',
|
||||
'gsm.basemap': 'Fond de carte', 'gsm.title': 'Carrés locator', 'gsm.digital': 'Numérique', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmés', 'gsm.worked': 'contactés', 'gsm.colConfirmed': 'Couleur des carrés confirmés', 'gsm.colWorked': 'Couleur des carrés contactés (non confirmés)', 'gsm.colReset': 'Revenir aux couleurs du thème', 'gsm.refresh': 'Recompter depuis le journal', 'gsm.count': '{n} carrés · {c} confirmés',
|
||||
'gsm.basemap': 'Fond de carte', 'gsm.title': 'Carrés locator', 'gsm.all': 'Tout', 'gsm.phone': 'Phonie', 'gsm.cw': 'CW', 'gsm.digital': 'Numérique', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmés', 'gsm.worked': 'contactés', 'gsm.colConfirmed': 'Couleur des carrés confirmés', 'gsm.colWorked': 'Couleur des carrés contactés (non confirmés)', 'gsm.colReset': 'Revenir aux couleurs du thème', 'gsm.refresh': 'Recompter depuis le journal', 'gsm.count': '{n} carrés · {c} confirmés',
|
||||
'bo.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que s’il a été collecté près de chez toi. Plus petit est plus local, mais laisse moins de récepteurs à l’écoute — trop petit, la veille n’a plus rien à observer. 300 km emprunte les oreilles de toute une région ; 100 km convient au 2 m, où un conduit est étroit.',
|
||||
'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot',
|
||||
'clu.macros': 'Boutons de commande', 'clu.macrosHint': 'Un bouton nommé à côté du champ de commande du cluster. Laisse la commande vide et le bouton n’est pas affiché.',
|
||||
|
||||
Reference in New Issue
Block a user