feat: status bar added
This commit is contained in:
@@ -65,6 +65,28 @@ const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
|
||||
};
|
||||
|
||||
// Small coloured dot + label used in the band-map legend strip.
|
||||
function LegendDot({ cls, label }: { cls: string; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={cn('size-2 rounded-full', cls)} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Human-readable label for a spot status — used in the pill hover tooltip
|
||||
// so the operator can see WHY a spot is coloured the way it is.
|
||||
function statusLabel(s: string): string {
|
||||
switch (s) {
|
||||
case 'new': return 'NEW DXCC (entity never worked)';
|
||||
case 'new-band': return 'NEW BAND (entity not worked on this band)';
|
||||
case 'new-slot': return 'NEW SLOT (mode not worked on this band)';
|
||||
case 'worked': return 'Worked (this band + mode already in log)';
|
||||
default: return 'Entity not resolved';
|
||||
}
|
||||
}
|
||||
|
||||
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
|
||||
// pill = full pill background+text+border
|
||||
// bar = thick left accent inside the pill
|
||||
@@ -114,6 +136,13 @@ const PILL_GAP = 32; // px between scale border and first pill (room for leade
|
||||
const LABEL_W = 200;
|
||||
const TOP_PAD = 14; // px of breathing room above/below the band edges so
|
||||
const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||
// Max FT-mode (FT8/FT4/…) pills drawn at once. These pile up on a single
|
||||
// watering-hole frequency and otherwise spawn hundreds of spots that fan out
|
||||
// and cover the whole map. ONLY the FT family is capped — CW, SSB and other
|
||||
// digital spots are always shown in full. When more than this FT spots are in
|
||||
// band we keep the most useful (new entities first, worked last; ties broken
|
||||
// by closeness to the rig freq).
|
||||
const MAX_VISIBLE_SPOTS = 30;
|
||||
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
|
||||
const range = BAND_RANGES[band];
|
||||
@@ -146,19 +175,53 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
// larger). When more labels stack than fit in the band's natural pixel
|
||||
// span, totalH grows so scrolling reveals them.
|
||||
type Placed = { spot: Spot; freqY: number; labelY: number };
|
||||
const { placed, totalH } = useMemo<{ placed: Placed[]; totalH: number }>(() => {
|
||||
const { placed, totalH, hidden } = useMemo<{ placed: Placed[]; totalH: number; hidden: number }>(() => {
|
||||
// innerH is the band's stretched pixel span; total adds top+bottom
|
||||
// padding so the edge freq labels aren't clipped at y=0 / y=H.
|
||||
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
|
||||
if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD };
|
||||
if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD, hidden: 0 };
|
||||
const seen = new Set<string>();
|
||||
const filtered: Spot[] = [];
|
||||
const inBand: Spot[] = [];
|
||||
for (const s of spots) {
|
||||
if (s.freq_khz < lo || s.freq_khz > hi) continue;
|
||||
if (seen.has(s.dx_call)) continue;
|
||||
seen.add(s.dx_call);
|
||||
filtered.push(s);
|
||||
inBand.push(s);
|
||||
}
|
||||
|
||||
// Only the FT family (FT8/FT4/…) is capped — it's what floods a single
|
||||
// watering-hole frequency. Everything else (CW, SSB, RTTY, PSK, …) is
|
||||
// always shown in full.
|
||||
const isFlood = (s: Spot) => /^FT/.test(inferSpotMode(s.comment ?? '', s.freq_hz));
|
||||
const ftSpots = inBand.filter(isFlood);
|
||||
const otherSpots = inBand.filter((s) => !isFlood(s));
|
||||
|
||||
// Rank an FT spot by usefulness (new entity → unworked → worked); ties
|
||||
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
|
||||
const rank = (s: Spot) => {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
switch (spotStatus[k]?.status ?? '') {
|
||||
case 'new': return 0;
|
||||
case 'new-band': return 1;
|
||||
case 'new-slot': return 2;
|
||||
case 'worked': return 4;
|
||||
default: return 3;
|
||||
}
|
||||
};
|
||||
let keptFt = ftSpots;
|
||||
let hiddenCount = 0;
|
||||
if (ftSpots.length > MAX_VISIBLE_SPOTS) {
|
||||
const rigK = currentFreqHz ? currentFreqHz / 1000 : (lo + hi) / 2;
|
||||
keptFt = [...ftSpots]
|
||||
.sort((a, b) => {
|
||||
const r = rank(a) - rank(b);
|
||||
if (r !== 0) return r;
|
||||
return Math.abs(a.freq_khz - rigK) - Math.abs(b.freq_khz - rigK);
|
||||
})
|
||||
.slice(0, MAX_VISIBLE_SPOTS);
|
||||
hiddenCount = ftSpots.length - keptFt.length;
|
||||
}
|
||||
const filtered = [...otherSpots, ...keptFt];
|
||||
filtered.sort((a, b) => b.freq_khz - a.freq_khz);
|
||||
|
||||
// Desired pill-CENTRE Y for each spot = its true frequency's Y.
|
||||
@@ -206,14 +269,32 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
return {
|
||||
placed: out,
|
||||
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
|
||||
hidden: hiddenCount,
|
||||
};
|
||||
}, [spots, range, lo, hi, span, pxPerKHz, containerH]);
|
||||
}, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz]);
|
||||
|
||||
// freqToY for elements rendered outside the memo (ticks, rig pointer).
|
||||
// Must mirror the same offset so the rig triangle sits on the right kHz.
|
||||
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
|
||||
const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;
|
||||
|
||||
// Auto-centre on the rig frequency when the map opens or the band changes
|
||||
// (once per band, so it doesn't fight the user's manual scrolling). Waits
|
||||
// for the scroller height to be measured and a valid in-band rig freq.
|
||||
const centeredForRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
if (!range || containerH <= 0 || currentFreqHz <= 0) return;
|
||||
const kHz = currentFreqHz / 1000;
|
||||
if (kHz < lo || kHz > hi) return;
|
||||
if (centeredForRef.current === band) return;
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
centeredForRef.current = band;
|
||||
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
||||
// freqToY is recomputed each render; intentionally excluded from deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [band, containerH, currentFreqHz, range, lo, hi]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
@@ -375,7 +456,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
{/* Pills absolutely positioned at their (anti-overlapped) Y */}
|
||||
{placed.map((p, i) => {
|
||||
const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
|
||||
const st = spotStatus[k]?.status ?? '';
|
||||
const entry = spotStatus[k];
|
||||
const st = entry?.status ?? '';
|
||||
const style = statusStyle(st);
|
||||
const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz);
|
||||
return (
|
||||
@@ -389,7 +471,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
'hover:translate-x-0.5 hover:shadow',
|
||||
style.pill,
|
||||
)}
|
||||
title={`${p.spot.dx_call} · ${p.spot.freq_khz.toFixed(1)} kHz${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
>
|
||||
{/* Status accent strip on the left */}
|
||||
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
|
||||
@@ -406,8 +488,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Colour legend — what each pill colour means. */}
|
||||
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
||||
<LegendDot cls="bg-rose-400" label="New DXCC" />
|
||||
<LegendDot cls="bg-amber-400" label="New band" />
|
||||
<LegendDot cls="bg-yellow-300" label="New slot (mode)" />
|
||||
<LegendDot cls="bg-muted-foreground/30" label="Worked" />
|
||||
</div>
|
||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
||||
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user