chore: release v0.26.0
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user