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. */} +