chore: release v0.26.0

This commit is contained in:
2026-08-20 17:53:55 +02:00
parent 47992b5f03
commit 1e507225dd
44 changed files with 3197 additions and 240 deletions
+227
View File
@@ -0,0 +1,227 @@
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.
function cssColour(token: string, fallback: string): string {
try {
const v = getComputedStyle(document.documentElement).getPropertyValue(token).trim();
return v || fallback;
} 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
// not a square worked on FT8.
const SCOPES = [
{ key: 'DIGI', label: 'gsm.digital' },
{ key: 'FTX', label: 'gsm.ftx' },
] as const;
type ScopeKey = typeof SCOPES[number]['key'];
const SCOPE_KEY = 'opslog.gridMapScope';
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);
// 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,
worldCopyJump: true,
minZoom: 1,
}).setView([25, 0], 2);
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
return () => { 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;
addBasemap(m, basemap, baseRef, labelsRef);
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 = cssColour('--success', '#16a34a');
const 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 : workedColour;
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]);
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>
<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. */}
<div ref={hostRef} className="flex-1 min-h-0" />
<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: '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: 'var(--chart-1)', opacity: 0.35 }} />
{t('gsm.worked')}
</span>
</footer>
</section>
);
}