diff --git a/frontend/src/components/GridSquareMap.tsx b/frontend/src/components/GridSquareMap.tsx
index 379f6af..ab71c8c 100644
--- a/frontend/src/components/GridSquareMap.tsx
+++ b/frontend/src/components/GridSquareMap.tsx
@@ -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 &&
{err}
}
{/* The map host must have a real height or Leaflet renders nothing at all
— flex-1 + min-h-0, never a percentage. */}
-
+ {/* 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. */}
+