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.
305 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|