Files
OpsLog/frontend/src/components/GridSquareMap.tsx
T
rouggy f2ba798764 fix(maps): zooming stays centred on the grid and decode maps
The hard maxBounds added for the single-world look fought the zoom: with
the world smaller than the window the clamp dragged every zoom step into
a corner. The noWrap tiles already guarantee one world on their own, so
the bounds go — on both maps.
2026-08-30 22:18:27 +02:00

305 lines
15 KiB
TypeScript

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<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const [squares, setSquares] = useState<Square[] | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [scope, setScope] = useState<ScopeKey>(
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
const [basemap, setBasemap] = useState<BasemapKey>(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,
}).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 });
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<L.TileLayer | null>(null);
const labelsRef = useRef<L.TileLayer | null>(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.
<section className={cn('isolate relative flex flex-col min-h-0 rounded-lg border border-border bg-card overflow-hidden', className)}>
<header className="flex items-center gap-2 px-2.5 py-1.5 border-b border-border bg-muted/40 shrink-0 flex-wrap">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('gsm.title')}
</span>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{SCOPES.map((s, i) => (
<button key={s.key} type="button"
onClick={() => { setScope(s.key); try { localStorage.setItem(SCOPE_KEY, s.key); } catch { /* quota */ } }}
className={cn('px-1.5 h-6 text-[11px] whitespace-nowrap', i > 0 && 'border-l border-border',
scope === s.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground')}>
{t(s.label)}
</button>
))}
</div>
<span className="text-[11px] text-muted-foreground tabular-nums">
{t('gsm.count', { n: stats.total, c: stats.confirmed })}
</span>
<select
value={basemap}
onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); writeUiPref('opslog.mapBasemap', v); }}
title={t('gsm.basemap')}
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
>
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
<option key={k} value={k}>{BASEMAPS[k].label}</option>
))}
</select>
{/* Beside the basemap, because they answer the same question: what this
map looks like. Written straight through — there is no Save here. */}
<label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colConfirmed')}>
<input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
value={asHex(confColour || cssColour('--success', '#16a34a'), '#16a34a')}
onChange={(e) => { setConfColour(e.target.value); writeUiPref(COL_CONFIRMED_KEY, e.target.value); }} />
{t('gsm.confirmed')}
</label>
<label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colWorked')}>
<input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
value={asHex(workedColour || cssColour('--chart-1', '#2a78d6'), '#2a78d6')}
onChange={(e) => { setWorkedColour(e.target.value); writeUiPref(COL_WORKED_KEY, e.target.value); }} />
{t('gsm.worked')}
</label>
{(confColour || workedColour) && (
<button type="button" title={t('gsm.colReset')}
onClick={() => {
setConfColour(''); setWorkedColour('');
writeUiPref(COL_CONFIRMED_KEY, ''); writeUiPref(COL_WORKED_KEY, '');
}}
className="text-[11px] text-muted-foreground hover:text-foreground px-1"></button>
)}
<span className="flex-1" />
<button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')}
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
{busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
</button>
</header>
{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. */}
{/* 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]"
style={{ background: confColour || 'var(--success)', opacity: 0.75 }} />
{t('gsm.confirmed')}
</span>
<span className="inline-flex items-center gap-1.5">
<span className="inline-block size-2.5 rounded-[2px]"
style={{ background: workedColour || 'var(--chart-1)', opacity: 0.35 }} />
{t('gsm.worked')}
</span>
</footer>
</section>
);
}