import { useEffect, useMemo, useRef, useState } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { Loader2, RefreshCw } from 'lucide-react'; import { GridSquares } from '../../wailsjs/go/main/App'; import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap'; import { writeUiPref } from '@/lib/uiPref'; // GridSquareMap — every Maidenhead square in the log, drawn on a world map. // // A square, not a marker. The question this answers is "where have I been // heard", and the shape of the answer is an area: a wall of squares across the // Atlantic and a bare Pacific says something about an antenna that a scatter of // pins does not. Confirmed and merely worked are two fills of the same hue — // the distinction is a STATE of one thing, not two categories, so it must not // be two unrelated colours. // // 4 characters, never 6: at world zoom a 6-character square is sub-pixel, and // aggregating to the big square is also how the count means "contacts in this // square" rather than "contacts at this exact spot". type Square = { grid: string; count: number; confirmed: boolean; band?: string; mode?: string }; // The SAME basemaps the Main-tab world map offers, imported rather than copied: // two lists of tile servers is how one map ends up on a provider the other has // already been blocked by. This map opened on a hardcoded dark Carto, which is // the least legible of the four under translucent squares. // Leaflet geometry is painted onto a CANVAS, and canvas takes a colour, not a // stylesheet: "var(--success)" handed to fillStyle is simply invalid and the // 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(); return v || fallback; } catch { return fallback; } } // 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; type ScopeKey = typeof SCOPES[number]['key']; const SCOPE_KEY = 'opslog.gridMapScope'; // Chosen fill colours. Empty means "follow the theme", which is the default and // stays the default: the tokens already track the four themes, and freezing a // hex at first run would leave a dark-theme map painted in the light palette. const COL_CONFIRMED_KEY = 'opslog.gridMapColorConfirmed'; const COL_WORKED_KEY = 'opslog.gridMapColorWorked'; // A colour input only accepts #rrggbb. The theme tokens ARE plain hex today, so // this normally just passes them through — but a token that ever becomes oklch() // or a named colour would silently drive the swatch to black, and a fallback is // cheaper than that debugging session. function asHex(v: string, fallback: string): string { return /^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : fallback; } export function GridSquareMap({ myGrid, className }: { myGrid?: string; className?: string }) { const { t } = useI18n(); const hostRef = useRef(null); const mapRef = useRef(null); const layerRef = useRef(null); const [squares, setSquares] = useState(null); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const [scope, setScope] = useState( () => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY)) ? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI')); const [basemap, setBasemap] = useState(loadBasemap); const [confColour, setConfColour] = useState(() => localStorage.getItem(COL_CONFIRMED_KEY) ?? ''); const [workedColour, setWorkedColour] = useState(() => localStorage.getItem(COL_WORKED_KEY) ?? ''); // Repaint the squares when the THEME changes, not the basemap: the fills come // from theme tokens resolved at draw time, so a theme switch leaves them on // the old palette until something forces a redraw. const [themeTick, setThemeTick] = useState(0); useEffect(() => { const obs = new MutationObserver(() => setThemeTick((n) => n + 1)); obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); return () => obs.disconnect(); }, []); const load = async (sc: ScopeKey = scope) => { setBusy(true); setErr(''); try { const r = (await GridSquares(sc)) as any; setSquares((Array.isArray(r) ? r : []) as Square[]); } catch (e: any) { setErr(String(e?.message ?? e)); setSquares([]); } finally { setBusy(false); } }; useEffect(() => { void load(scope); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope]); // One-time map creation. preferCanvas: a busy digital log is a few thousand // rectangles, and as SVG that is a few thousand DOM nodes to lay out on every // pan. useEffect(() => { if (!hostRef.current || mapRef.current) return; const m = L.map(hostRef.current, { preferCanvas: true, zoomControl: true, attributionControl: true, // 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 // looks again. Here that measurement happens while the panel is still // laying out — so the map kept the height it had at that instant and the // 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. // 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; }; }, []); // The chosen basemap, shared with the Main-tab map so picking one there and // finding another here cannot happen. const baseRef = useRef(null); const labelsRef = useRef(null); useEffect(() => { const m = mapRef.current; if (!m) return; // 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]); // Redraw the squares. useEffect(() => { const layer = layerRef.current; if (!layer) return; layer.clearLayers(); // Resolved once for the whole redraw, not per square: getComputedStyle // forces a style flush, and doing that a thousand times is a visible stall. const confirmedColour = confColour || cssColour('--success', '#16a34a'); const workedFill = workedColour || cssColour('--chart-1', '#2a78d6'); const meColour = cssColour('--warning', '#f59e0b'); for (const sq of squares ?? []) { const b = gridSquareBounds(sq.grid); if (!b) continue; // One hue, two states. Confirmed is the solid, saturated one; worked is // the same colour held back — so the eye reads "more" and "less" of the // same thing rather than two unrelated facts. const colour = sq.confirmed ? confirmedColour : workedFill; L.rectangle([[b.south, b.west], [b.north, b.east]], { color: colour, weight: 0.5, opacity: sq.confirmed ? 0.9 : 0.5, fillColor: colour, fillOpacity: sq.confirmed ? 0.55 : 0.22, }) .bindTooltip( `${sq.grid} — ${sq.count} QSO${sq.count > 1 ? 's' : ''}` + `${sq.band ? ` · ${sq.band}` : ''}${sq.mode ? ` ${sq.mode}` : ''}` + `${sq.confirmed ? ` · ${t('gsm.confirmed')}` : ''}`, { sticky: true }, ) .addTo(layer); } // The operator's own square, so the pattern has an origin to be read from. const me = myGrid ? gridToLatLon(myGrid) : null; if (me) { L.circleMarker([me.lat, me.lon], { radius: 4, color: meColour, weight: 2, fillColor: meColour, fillOpacity: 1, }).bindTooltip(myGrid!.toUpperCase(), { sticky: true }).addTo(layer); } }, [squares, myGrid, t, themeTick, confColour, workedColour]); const stats = useMemo(() => { const list = squares ?? []; return { total: list.length, confirmed: list.filter((s) => s.confirmed).length }; }, [squares]); return ( // isolate is load-bearing, not tidiness. Leaflet puts its panes at z-index // 400 and its zoom control at 1000, in the PAGE's stacking context — and the // modals here are z-50. Without a stacking context of its own the map floats // over Preferences and hides the Save and Close buttons. isolation:isolate // confines every z-index Leaflet sets to this element.
{t('gsm.title')}
{SCOPES.map((s, i) => ( ))}
{t('gsm.count', { n: stats.total, c: stats.confirmed })} {/* Beside the basemap, because they answer the same question: what this map looks like. Written straight through — there is no Save here. */} {(confColour || workedColour) && ( )}
{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. */}
); }