up
This commit is contained in:
@@ -228,11 +228,18 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
})
|
})
|
||||||
a.reloadCAT()
|
a.reloadCAT()
|
||||||
|
|
||||||
// DX Cluster (multi-server): spot callback pushes individual spots,
|
// DX Cluster (multi-server): the spot callback enriches each spot
|
||||||
// status callback signals "something changed" so the frontend can
|
// with country + continent via cty.dat BEFORE emitting it, so the UI
|
||||||
// fetch the aggregate via GetClusterStatus.
|
// renders the row with all metadata already filled (no flicker of
|
||||||
|
// empty Country / Cont columns while the batch status fetch runs).
|
||||||
a.cluster = cluster.NewManager(
|
a.cluster = cluster.NewManager(
|
||||||
func(s cluster.Spot) {
|
func(s cluster.Spot) {
|
||||||
|
if a.dxcc != nil {
|
||||||
|
if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil {
|
||||||
|
s.Country = m.Entity.Name
|
||||||
|
s.Continent = m.Continent
|
||||||
|
}
|
||||||
|
}
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "cluster:spot", s)
|
wruntime.EventsEmit(a.ctx, "cluster:spot", s)
|
||||||
}
|
}
|
||||||
@@ -1383,11 +1390,12 @@ type SpotQuery struct {
|
|||||||
// "worked" — exact band+mode already in the log
|
// "worked" — exact band+mode already in the log
|
||||||
// "" — couldn't resolve the entity (no cty.dat match)
|
// "" — couldn't resolve the entity (no cty.dat match)
|
||||||
type SpotStatus struct {
|
type SpotStatus struct {
|
||||||
Call string `json:"call"`
|
Call string `json:"call"`
|
||||||
Band string `json:"band"`
|
Band string `json:"band"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
Status string `json:"status"`
|
Continent string `json:"continent,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClusterSpotStatuses takes a batch of spots and returns slot status for
|
// ClusterSpotStatuses takes a batch of spots and returns slot status for
|
||||||
@@ -1403,7 +1411,20 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
entities, err := a.qso.EntitySlotMap(a.ctx)
|
// Pass a cty.dat-backed resolver so the past-QSO map uses the SAME
|
||||||
|
// entity name we'll compare each spot against. Without it QRZ-stored
|
||||||
|
// "Turkey" wouldn't match cty.dat's "Asiatic Turkey" → false NEW.
|
||||||
|
resolveEntity := func(callsign string) string {
|
||||||
|
if a.dxcc == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
m, ok := a.dxcc.Lookup(callsign)
|
||||||
|
if !ok || m.Entity == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.Entity.Name
|
||||||
|
}
|
||||||
|
entities, err := a.qso.EntitySlotMap(a.ctx, resolveEntity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -1422,6 +1443,7 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
}
|
}
|
||||||
country := strings.ToLower(m.Entity.Name)
|
country := strings.ToLower(m.Entity.Name)
|
||||||
out[i].Country = m.Entity.Name
|
out[i].Country = m.Entity.Name
|
||||||
|
out[i].Continent = m.Continent
|
||||||
e, worked := entities[country]
|
e, worked := entities[country]
|
||||||
if !worked {
|
if !worked {
|
||||||
out[i].Status = "new"
|
out[i].Status = "new"
|
||||||
|
|||||||
+71
-25
@@ -353,6 +353,8 @@ export default function App() {
|
|||||||
comment?: string;
|
comment?: string;
|
||||||
locator?: string;
|
locator?: string;
|
||||||
time_utc?: string;
|
time_utc?: string;
|
||||||
|
country?: string;
|
||||||
|
continent?: string;
|
||||||
received_at: string;
|
received_at: string;
|
||||||
raw: string;
|
raw: string;
|
||||||
};
|
};
|
||||||
@@ -392,7 +394,7 @@ export default function App() {
|
|||||||
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
||||||
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
||||||
// different slots don't share the same colour.
|
// different slots don't share the same colour.
|
||||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string }>>({});
|
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string }>>({});
|
||||||
|
|
||||||
// === Modals ===
|
// === Modals ===
|
||||||
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
||||||
@@ -569,7 +571,24 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reloadClusterMeta();
|
reloadClusterMeta();
|
||||||
EventsOn('cluster:state', (sts: ServerStatus[]) => setClusterServerStatuses(sts ?? []));
|
// cluster:state fires on connect/disconnect/save/delete — refresh
|
||||||
|
// the saved-server list too so the source dropdown stays in sync
|
||||||
|
// when the user adds, deletes or toggles a row in Settings.
|
||||||
|
EventsOn('cluster:state', async (sts: ServerStatus[]) => {
|
||||||
|
setClusterServerStatuses(sts ?? []);
|
||||||
|
try {
|
||||||
|
const list = await ListClusterServers();
|
||||||
|
setClusterServers(((list ?? []) as any[]).map((s) => ({
|
||||||
|
id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0,
|
||||||
|
})));
|
||||||
|
} catch {}
|
||||||
|
// Drop any buffered spots whose source server is no longer in the
|
||||||
|
// status list (it was disconnected / deleted). Without this the
|
||||||
|
// table keeps showing stale rows from a server the user just
|
||||||
|
// turned off.
|
||||||
|
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
|
||||||
|
setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
|
||||||
|
});
|
||||||
EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
||||||
setSpots((arr) => {
|
setSpots((arr) => {
|
||||||
const next = [sp, ...arr];
|
const next = [sp, ...arr];
|
||||||
@@ -604,7 +623,7 @@ export default function App() {
|
|||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const r of res) {
|
for (const r of res) {
|
||||||
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
||||||
next[k] = { status: r.status ?? '', country: r.country };
|
next[k] = { status: r.status ?? '', country: r.country, continent: (r as any).continent };
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -1791,6 +1810,8 @@ export default function App() {
|
|||||||
{ key: 'freq', label: 'Freq', align: 'right' },
|
{ key: 'freq', label: 'Freq', align: 'right' },
|
||||||
{ key: 'band', label: 'Band' },
|
{ key: 'band', label: 'Band' },
|
||||||
{ key: 'mode', label: 'Mode' },
|
{ key: 'mode', label: 'Mode' },
|
||||||
|
{ key: null, label: 'Country' },
|
||||||
|
{ key: null, label: 'Cont' },
|
||||||
{ key: 'spotter', label: 'Spotter' },
|
{ key: 'spotter', label: 'Spotter' },
|
||||||
{ key: 'source', label: 'Source' },
|
{ key: 'source', label: 'Source' },
|
||||||
{ key: null, label: 'Loc' },
|
{ key: null, label: 'Loc' },
|
||||||
@@ -1800,19 +1821,14 @@ export default function App() {
|
|||||||
s.key === k
|
s.key === k
|
||||||
? { key: k, dir: s.dir === 'asc' ? 'desc' : 'asc' }
|
? { key: k, dir: s.dir === 'asc' ? 'desc' : 'asc' }
|
||||||
: { key: k, dir: k === 'time' || k === 'freq' ? 'desc' : 'asc' });
|
: { key: k, dir: k === 'time' || k === 'freq' ? 'desc' : 'asc' });
|
||||||
const rowColor = (s: ClusterSpot): string => {
|
// Log4OM-style per-cell highlight: the badge that matches
|
||||||
// The cache key includes the inferred mode (from
|
// the "what's new" gets coloured instead of the whole row.
|
||||||
// comment / band-plan) so CW vs FT8 on the same
|
// CALL = new entity, BAND = new band for entity, MODE = new
|
||||||
// band get distinct statuses.
|
// mode for that band (NEW SLOT — Log4OM doesn't show this
|
||||||
|
// but the user wants it).
|
||||||
|
const cellHL = (s: ClusterSpot) => {
|
||||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||||
const st = spotStatus[k];
|
return spotStatus[k]?.status ?? '';
|
||||||
if (!st) return '';
|
|
||||||
switch (st.status) {
|
|
||||||
case 'new': return 'bg-rose-50 hover:bg-rose-100';
|
|
||||||
case 'new-band': return 'bg-amber-50 hover:bg-amber-100';
|
|
||||||
case 'new-slot': return 'bg-yellow-50 hover:bg-yellow-100';
|
|
||||||
default: return '';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<table className="w-full border-collapse text-[12.5px]">
|
<table className="w-full border-collapse text-[12.5px]">
|
||||||
@@ -1844,10 +1860,22 @@ export default function App() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rendered.map((s, i) => (
|
{rendered.map((s, i) => {
|
||||||
|
const hl = cellHL(s);
|
||||||
|
const callCls = hl === 'new'
|
||||||
|
? 'bg-rose-100 text-rose-800 hover:bg-rose-200 border border-rose-300'
|
||||||
|
: 'text-primary';
|
||||||
|
const bandCls = hl === 'new-band'
|
||||||
|
? 'bg-amber-200 text-amber-900 border-amber-500 hover:bg-amber-200'
|
||||||
|
: '';
|
||||||
|
const modeMode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||||
|
const modeCls = hl === 'new-slot'
|
||||||
|
? 'bg-yellow-200 text-yellow-900 border-yellow-500 hover:bg-yellow-200'
|
||||||
|
: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100';
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={`${s.received_at}-${s.dx_call}-${i}`}
|
key={`${s.received_at}-${s.dx_call}-${i}`}
|
||||||
className={cn('cursor-pointer', rowColor(s) || 'hover:bg-accent/30')}
|
className="cursor-pointer hover:bg-accent/30"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Mode comes from the spot itself (comment text
|
// Mode comes from the spot itself (comment text
|
||||||
// first, band plan fallback). Sending it to CAT
|
// first, band plan fallback). Sending it to CAT
|
||||||
@@ -1871,20 +1899,38 @@ export default function App() {
|
|||||||
className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40"
|
className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40"
|
||||||
title={s.repeats && s.repeats > 1 ? `Seen ${s.repeats}× across active clusters` : undefined}
|
title={s.repeats && s.repeats > 1 ? `Seen ${s.repeats}× across active clusters` : undefined}
|
||||||
>{s.time_utc || ''}</td>
|
>{s.time_utc || ''}</td>
|
||||||
<td className="px-2.5 py-1.5 font-mono font-bold text-primary whitespace-nowrap border-b border-border/40">{s.dx_call}</td>
|
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">
|
||||||
|
<span
|
||||||
|
className={cn('font-mono font-bold inline-block px-1 rounded', callCls)}
|
||||||
|
title={hl === 'new' ? `NEW DXCC: ${spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.country ?? ''}` : undefined}
|
||||||
|
>
|
||||||
|
{s.dx_call}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="px-2.5 py-1.5 font-mono text-right whitespace-nowrap border-b border-border/40">{s.freq_khz.toFixed(1)}</td>
|
<td className="px-2.5 py-1.5 font-mono text-right whitespace-nowrap border-b border-border/40">{s.freq_khz.toFixed(1)}</td>
|
||||||
<td className="px-2.5 py-1.5 border-b border-border/40"><Badge variant="accent" className="font-mono text-[10px] py-0">{s.band || '—'}</Badge></td>
|
<td className="px-2.5 py-1.5 border-b border-border/40">
|
||||||
<td className="px-2.5 py-1.5 border-b border-border/40">{(() => {
|
{bandCls
|
||||||
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
|
? <Badge className={cn('font-mono text-[10px] py-0', bandCls)} variant="outline" title="NEW BAND for this entity">{s.band || '—'}</Badge>
|
||||||
if (!m) return <span className="text-muted-foreground text-[10px]">—</span>;
|
: <Badge variant="accent" className="font-mono text-[10px] py-0">{s.band || '—'}</Badge>}
|
||||||
return <Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono text-[10px] py-0" variant="outline">{m}</Badge>;
|
</td>
|
||||||
})()}</td>
|
<td className="px-2.5 py-1.5 border-b border-border/40">
|
||||||
|
{!modeMode
|
||||||
|
? <span className="text-muted-foreground text-[10px]">—</span>
|
||||||
|
: <Badge className={cn('font-mono text-[10px] py-0', modeCls)} variant="outline" title={hl === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined}>{modeMode}</Badge>}
|
||||||
|
</td>
|
||||||
|
<td className="px-2.5 py-1.5 text-[12px] text-muted-foreground whitespace-nowrap border-b border-border/40">
|
||||||
|
{s.country ?? spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.country ?? ''}
|
||||||
|
</td>
|
||||||
|
<td className="px-2.5 py-1.5 font-mono text-muted-foreground text-[10px] whitespace-nowrap border-b border-border/40">
|
||||||
|
{s.continent ?? spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.continent ?? ''}
|
||||||
|
</td>
|
||||||
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{cleanSpotter(s.spotter)}</td>
|
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{cleanSpotter(s.spotter)}</td>
|
||||||
<td className="px-2.5 py-1.5 font-mono text-muted-foreground/60 text-[10px] whitespace-nowrap border-b border-border/40">{s.source_name}</td>
|
<td className="px-2.5 py-1.5 font-mono text-muted-foreground/60 text-[10px] whitespace-nowrap border-b border-border/40">{s.source_name}</td>
|
||||||
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{s.locator || ''}</td>
|
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{s.locator || ''}</td>
|
||||||
<td className="px-2.5 py-1.5 text-muted-foreground border-b border-border/40">{s.comment}</td>
|
<td className="px-2.5 py-1.5 text-muted-foreground border-b border-border/40">{s.comment}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
);
|
);
|
||||||
|
|||||||
+184
-127
@@ -1,13 +1,16 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Minus, Plus, Crosshair, X } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { spotStatusKey } from '@/lib/spot';
|
import { spotStatusKey, inferSpotMode } from '@/lib/spot';
|
||||||
|
|
||||||
// BandMap — vertical spectrum panel. Layout follows Log4OM's well-loved
|
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||||
// design: a kHz scale on the left, callsign labels stacked vertically on
|
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
||||||
// the right (one per line, no overlap), connected to their actual
|
// navigates the band (so 16× lets you read each spot, not crops the
|
||||||
// frequency on the scale by diagonal "leader" lines. Wheel-scroll for
|
// band to a 22 kHz slice).
|
||||||
// long spot lists, Ctrl+wheel to zoom.
|
// - Labels sit at their actual frequency by default (straight horizontal
|
||||||
|
// leader line). Only when two labels would visually overlap do we
|
||||||
|
// bump the lower one down — its leader then becomes diagonal back to
|
||||||
|
// the true frequency.
|
||||||
|
|
||||||
interface Spot {
|
interface Spot {
|
||||||
source_id?: number;
|
source_id?: number;
|
||||||
@@ -31,8 +34,6 @@ interface Props {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visible kHz range per band — covers IARU R1 plus a small pad so spots
|
|
||||||
// right at the edge are still drawn.
|
|
||||||
const BAND_RANGES: Record<string, [number, number]> = {
|
const BAND_RANGES: Record<string, [number, number]> = {
|
||||||
'160m': [1800, 2000],
|
'160m': [1800, 2000],
|
||||||
'80m': [3500, 3800],
|
'80m': [3500, 3800],
|
||||||
@@ -64,33 +65,63 @@ const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
|||||||
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
|
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
|
||||||
};
|
};
|
||||||
|
|
||||||
function statusColor(s: string): { fg: string; line: string } {
|
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
|
||||||
// fg is the label text colour; line is the SVG stroke. Both follow the
|
// pill = full pill background+text+border
|
||||||
// same NEW / NEW BAND / NEW SLOT / WORKED palette as the spot table.
|
// bar = thick left accent inside the pill
|
||||||
|
// line = SVG leader stroke (visible on hover)
|
||||||
|
// dot = small marker on the freq scale
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'new': return { fg: 'text-rose-700', line: 'stroke-rose-500' };
|
case 'new': return {
|
||||||
case 'new-band': return { fg: 'text-amber-700', line: 'stroke-amber-500' };
|
pill: 'bg-rose-50 text-rose-900 border-rose-200 hover:bg-rose-100',
|
||||||
case 'new-slot': return { fg: 'text-yellow-700', line: 'stroke-yellow-600' };
|
bar: 'bg-rose-500',
|
||||||
case 'worked': return { fg: 'text-muted-foreground', line: 'stroke-border' };
|
line: 'stroke-rose-400',
|
||||||
default: return { fg: 'text-emerald-700', line: 'stroke-emerald-600' };
|
dot: 'fill-rose-500',
|
||||||
|
};
|
||||||
|
case 'new-band': return {
|
||||||
|
pill: 'bg-amber-50 text-amber-900 border-amber-200 hover:bg-amber-100',
|
||||||
|
bar: 'bg-amber-500',
|
||||||
|
line: 'stroke-amber-400',
|
||||||
|
dot: 'fill-amber-500',
|
||||||
|
};
|
||||||
|
case 'new-slot': return {
|
||||||
|
pill: 'bg-yellow-50 text-yellow-900 border-yellow-200 hover:bg-yellow-100',
|
||||||
|
bar: 'bg-yellow-500',
|
||||||
|
line: 'stroke-yellow-500',
|
||||||
|
dot: 'fill-yellow-500',
|
||||||
|
};
|
||||||
|
case 'worked': return {
|
||||||
|
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
|
||||||
|
bar: 'bg-muted-foreground/30',
|
||||||
|
line: 'stroke-border',
|
||||||
|
dot: 'fill-border',
|
||||||
|
};
|
||||||
|
default: return {
|
||||||
|
pill: 'bg-card text-foreground border-border hover:bg-accent/40',
|
||||||
|
bar: 'bg-primary/60',
|
||||||
|
line: 'stroke-primary/50',
|
||||||
|
dot: 'fill-primary/60',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ZOOMS = [1, 2, 4, 8, 16];
|
// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot
|
||||||
const SCALE_W = 56; // px — left freq scale column
|
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
|
||||||
const LINE_H = 18; // px — per-callsign row height
|
// most bands. Higher levels are for fine inspection of crowded sub-bands.
|
||||||
const LABEL_PAD_LEFT = 24; // px — diagonal line lands here, before the text
|
const PX_PER_KHZ = [8, 16, 32, 64, 128, 256];
|
||||||
|
const SCALE_W = 56;
|
||||||
|
const PILL_H = 22; // px — height of each callsign pill
|
||||||
|
const PILL_GAP = 32; // px between scale border and first pill (room for leader)
|
||||||
|
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
|
||||||
|
|
||||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
|
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
|
||||||
const range = BAND_RANGES[band];
|
const range = BAND_RANGES[band];
|
||||||
const segments = SEGMENT_COLORS[band] ?? [];
|
const segments = SEGMENT_COLORS[band] ?? [];
|
||||||
const [zoomIdx, setZoomIdx] = useState(0);
|
const [zoomIdx, setZoomIdx] = useState(0);
|
||||||
const [center, setCenter] = useState<number | null>(null);
|
|
||||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const innerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [containerH, setContainerH] = useState(400);
|
const [containerH, setContainerH] = useState(400);
|
||||||
|
|
||||||
// Track the visible container height so we can stretch the scale.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollerRef.current;
|
const el = scrollerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -100,31 +131,55 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Window geometry.
|
|
||||||
const zoom = ZOOMS[zoomIdx];
|
|
||||||
const fallback: [number, number] = range ?? [0, 1];
|
const fallback: [number, number] = range ?? [0, 1];
|
||||||
const [bandLo, bandHi] = fallback;
|
const [lo, hi] = fallback;
|
||||||
const visSpan = (bandHi - bandLo) / zoom;
|
|
||||||
const c0 = center ?? (currentFreqHz > 0 ? currentFreqHz / 1000 : (bandLo + (bandHi - bandLo) / 2));
|
|
||||||
const c = clampCenter(c0, fallback, zoom);
|
|
||||||
const lo = c - visSpan / 2;
|
|
||||||
const hi = c + visSpan / 2;
|
|
||||||
const span = hi - lo;
|
const span = hi - lo;
|
||||||
|
const pxPerKHz = PX_PER_KHZ[zoomIdx];
|
||||||
|
|
||||||
// Filtered + sorted spots (highest freq first → top of the column).
|
// Anti-overlap layout: each label wants to sit at its true freq, but
|
||||||
const visible = useMemo(() => {
|
// never closer than PILL_H from the previous one. Sorted top-to-bottom
|
||||||
if (!range) return [];
|
// (highest freq first). Dedup by callsign (latest wins) so multi-cluster
|
||||||
return spots
|
// duplicates don't stack identical pills.
|
||||||
.filter((s) => s.freq_khz >= lo && s.freq_khz <= hi)
|
//
|
||||||
.sort((a, b) => b.freq_khz - a.freq_khz);
|
// Returns placed labels + the required content height (the natural
|
||||||
}, [spots, lo, hi, range]);
|
// band height OR the bottom of the last bumped label, whichever is
|
||||||
|
// 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 }>(() => {
|
||||||
|
// 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 };
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const filtered: 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);
|
||||||
|
}
|
||||||
|
filtered.sort((a, b) => b.freq_khz - a.freq_khz);
|
||||||
|
const out: Placed[] = [];
|
||||||
|
let prevY = -Infinity;
|
||||||
|
for (const s of filtered) {
|
||||||
|
const fy = TOP_PAD + (1 - (s.freq_khz - lo) / span) * innerH;
|
||||||
|
const ly = Math.max(fy, prevY + PILL_H);
|
||||||
|
out.push({ spot: s, freqY: fy, labelY: ly });
|
||||||
|
prevY = ly;
|
||||||
|
}
|
||||||
|
const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0;
|
||||||
|
return {
|
||||||
|
placed: out,
|
||||||
|
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
|
||||||
|
};
|
||||||
|
}, [spots, range, lo, hi, span, pxPerKHz, containerH]);
|
||||||
|
|
||||||
// Total content height: stretch so every label has its own row, but
|
// freqToY for elements rendered outside the memo (ticks, rig pointer).
|
||||||
// never shrink below the visible container so the scale fills the box
|
// Must mirror the same offset so the rig triangle sits on the right kHz.
|
||||||
// when there are few spots.
|
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
|
||||||
const totalH = Math.max(containerH, visible.length * LINE_H + 16);
|
const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;
|
||||||
|
|
||||||
// Ctrl+wheel = zoom, regular wheel = native scroll (default browser).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollerRef.current;
|
const el = scrollerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -132,7 +187,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
if (!range) return;
|
if (!range) return;
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setZoomIdx((z) => Math.max(0, Math.min(ZOOMS.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
|
setZoomIdx((z) => Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
@@ -148,29 +203,25 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tick step adapts to visible kHz span so labels stay legible.
|
// Tick step (where small marks land) and label step (where the kHz
|
||||||
let step = 100;
|
// number is printed) are decoupled so the scale shows ~10-20 numeric
|
||||||
if (span <= 1500) step = 50;
|
// labels per viewport regardless of zoom — at base 8 px/kHz we want
|
||||||
if (span <= 800) step = 25;
|
// labels every 25 kHz, not every 250.
|
||||||
if (span <= 300) step = 10;
|
let tickStep = 50;
|
||||||
if (span <= 100) step = 5;
|
let labelStep = 100;
|
||||||
if (span <= 40) step = 2;
|
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
|
||||||
if (span <= 20) step = 1;
|
if (pxPerKHz >= 8) { tickStep = 5; labelStep = 25; }
|
||||||
|
if (pxPerKHz >= 16) { tickStep = 2; labelStep = 10; }
|
||||||
|
if (pxPerKHz >= 32) { tickStep = 1; labelStep = 5; }
|
||||||
|
if (pxPerKHz >= 64) { tickStep = 1; labelStep = 2; }
|
||||||
|
if (pxPerKHz >= 128) { tickStep = 1; labelStep = 1; }
|
||||||
const ticks: number[] = [];
|
const ticks: number[] = [];
|
||||||
for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) ticks.push(t);
|
for (let t = Math.ceil(lo / tickStep) * tickStep; t <= hi; t += tickStep) ticks.push(t);
|
||||||
|
|
||||||
// Y-axis convention: HIGH frequency at top, LOW at bottom (matches a
|
|
||||||
// physical receiver dial). freqToY maps a kHz to pixel-Y in totalH.
|
|
||||||
const freqToY = (kHz: number) => (1 - (kHz - lo) / span) * totalH;
|
|
||||||
|
|
||||||
function recenterOnRig() {
|
function recenterOnRig() {
|
||||||
if (currentFreqHz > 0) setCenter(clampCenter(currentFreqHz / 1000, range, zoom));
|
if (!scrollerRef.current || currentFreqHz <= 0) return;
|
||||||
else setCenter(null);
|
const y = freqToY(currentFreqHz / 1000);
|
||||||
// Also scroll to keep the rig pointer in view.
|
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
|
||||||
if (scrollerRef.current && currentFreqHz > 0) {
|
|
||||||
const y = freqToY(currentFreqHz / 1000);
|
|
||||||
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
|
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
|
||||||
@@ -186,15 +237,15 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
title="Zoom out">
|
title="Zoom out">
|
||||||
<Minus className="size-3" />
|
<Minus className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
<span className="font-mono text-[10px] w-7 text-center">{zoom}×</span>
|
<span className="font-mono text-[10px] w-12 text-center">{pxPerKHz}px/kHz</span>
|
||||||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(ZOOMS.length - 1, z + 1))} disabled={zoomIdx === ZOOMS.length - 1}
|
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={zoomIdx === PX_PER_KHZ.length - 1}
|
||||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
title="Zoom in">
|
title="Zoom in">
|
||||||
<Plus className="size-3" />
|
<Plus className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={recenterOnRig}
|
<button type="button" onClick={recenterOnRig}
|
||||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||||
title="Center on current rig frequency">
|
title="Scroll to current rig frequency">
|
||||||
<Crosshair className="size-3" />
|
<Crosshair className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
@@ -207,8 +258,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollerRef} className="flex-1 overflow-y-auto overflow-x-hidden relative">
|
<div ref={scrollerRef} className="flex-1 overflow-y-auto overflow-x-hidden relative">
|
||||||
<div ref={innerRef} className="relative" style={{ height: totalH }}>
|
<div className="relative" style={{ height: totalH, minWidth: SCALE_W + PILL_GAP + LABEL_W }}>
|
||||||
{/* Scale column background — full height, segments stretched */}
|
{/* Scale background segments — stretched to total height */}
|
||||||
<svg
|
<svg
|
||||||
className="absolute top-0 left-0 pointer-events-none"
|
className="absolute top-0 left-0 pointer-events-none"
|
||||||
width={SCALE_W}
|
width={SCALE_W}
|
||||||
@@ -216,19 +267,17 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
{segments.map(([s, e, cls], i) => {
|
{segments.map(([s, e, cls], i) => {
|
||||||
if (e < lo || s > hi) return null;
|
|
||||||
const y1 = freqToY(Math.min(e, hi));
|
const y1 = freqToY(Math.min(e, hi));
|
||||||
const y2 = freqToY(Math.max(s, lo));
|
const y2 = freqToY(Math.max(s, lo));
|
||||||
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
|
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
|
||||||
})}
|
})}
|
||||||
{/* Scale border */}
|
|
||||||
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Tick marks + labels on scale */}
|
{/* Tick marks + freq labels */}
|
||||||
{ticks.map((t) => {
|
{ticks.map((t) => {
|
||||||
const y = freqToY(t);
|
const y = freqToY(t);
|
||||||
const major = t % (step * 5) === 0;
|
const major = t % labelStep === 0;
|
||||||
return (
|
return (
|
||||||
<div key={t} className="absolute left-0 flex items-center pointer-events-none" style={{ top: y, transform: 'translateY(-50%)', width: SCALE_W }}>
|
<div key={t} className="absolute left-0 flex items-center pointer-events-none" style={{ top: y, transform: 'translateY(-50%)', width: SCALE_W }}>
|
||||||
<div className={cn('border-t', major ? 'w-full border-foreground/40' : 'w-3 border-border/60')} />
|
<div className={cn('border-t', major ? 'w-full border-foreground/40' : 'w-3 border-border/60')} />
|
||||||
@@ -241,83 +290,91 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* SVG layer for leader lines + rig pointer */}
|
{/* Dots on the scale + leader lines (always-on, subtle) + rig pointer */}
|
||||||
<svg
|
<svg className="absolute inset-0 pointer-events-none" width="100%" height={totalH} preserveAspectRatio="none">
|
||||||
className="absolute inset-0 pointer-events-none"
|
{placed.map((p, i) => {
|
||||||
width="100%"
|
const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
|
||||||
height={totalH}
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
>
|
|
||||||
{visible.map((s, i) => {
|
|
||||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
|
||||||
const st = spotStatus[k]?.status ?? '';
|
const st = spotStatus[k]?.status ?? '';
|
||||||
const color = statusColor(st);
|
const style = statusStyle(st);
|
||||||
const fy = freqToY(s.freq_khz);
|
const labelMidY = p.labelY + PILL_H / 2;
|
||||||
const ly = i * LINE_H + LINE_H / 2 + 8;
|
const bumped = Math.abs(p.freqY - labelMidY) > 0.5;
|
||||||
return (
|
return (
|
||||||
<line
|
<g key={`l-${i}-${p.spot.dx_call}`}>
|
||||||
key={`l-${i}-${s.dx_call}`}
|
{/* Small dot on the scale where the spot actually is */}
|
||||||
x1={SCALE_W}
|
<circle cx={SCALE_W - 2} cy={p.freqY} r={3} className={style.dot} />
|
||||||
y1={fy}
|
{/* Leader line — solid+full opacity when straight,
|
||||||
x2={SCALE_W + LABEL_PAD_LEFT}
|
dashed+lower opacity when diagonal (bumped) so the
|
||||||
y2={ly}
|
eye distinguishes "this is the real freq" from
|
||||||
className={color.line}
|
"this label was nudged to fit". */}
|
||||||
strokeWidth={1}
|
<line
|
||||||
/>
|
x1={SCALE_W + 1}
|
||||||
|
y1={p.freqY}
|
||||||
|
x2={SCALE_W + PILL_GAP - 2}
|
||||||
|
y2={labelMidY}
|
||||||
|
className={cn(style.line, bumped && 'opacity-60')}
|
||||||
|
strokeWidth={bumped ? 1 : 1.5}
|
||||||
|
strokeDasharray={bumped ? '2 2' : ''}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{showRigPointer && (
|
{showRigPointer && (
|
||||||
<>
|
<>
|
||||||
|
{/* Triangle pointer + soft horizontal target line */}
|
||||||
<polygon
|
<polygon
|
||||||
points={`${SCALE_W - 1},${rigY - 4} ${SCALE_W + 5},${rigY} ${SCALE_W - 1},${rigY + 4}`}
|
points={`${SCALE_W - 6},${rigY - 5} ${SCALE_W + 1},${rigY} ${SCALE_W - 6},${rigY + 5}`}
|
||||||
className="fill-primary"
|
className="fill-primary drop-shadow-sm"
|
||||||
/>
|
/>
|
||||||
<line
|
<line
|
||||||
x1={SCALE_W + 5}
|
x1={SCALE_W + 1}
|
||||||
y1={rigY}
|
y1={rigY}
|
||||||
x2="100%"
|
x2="100%"
|
||||||
y2={rigY}
|
y2={rigY}
|
||||||
className="stroke-primary/40"
|
className="stroke-primary/30"
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="4 4"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Callsign label stack — one per line, sorted by freq desc */}
|
{/* Pills absolutely positioned at their (anti-overlapped) Y */}
|
||||||
<div className="absolute" style={{ left: SCALE_W + LABEL_PAD_LEFT, top: 8, right: 0 }}>
|
{placed.map((p, i) => {
|
||||||
{visible.map((s, i) => {
|
const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
|
||||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
const st = spotStatus[k]?.status ?? '';
|
||||||
const st = spotStatus[k]?.status ?? '';
|
const style = statusStyle(st);
|
||||||
const color = statusColor(st);
|
const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${s.freq_khz}-${s.dx_call}-${i}`}
|
key={`${p.spot.freq_khz}-${p.spot.dx_call}-${i}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSpotClick(s)}
|
onClick={() => onSpotClick(p.spot)}
|
||||||
style={{ height: LINE_H, lineHeight: `${LINE_H}px` }}
|
style={{ top: p.labelY, left: SCALE_W + PILL_GAP, height: PILL_H }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'block w-full text-left px-1 font-mono text-[11px] font-bold hover:bg-accent/30 transition-colors whitespace-nowrap',
|
'absolute inline-flex items-stretch overflow-hidden rounded-md border shadow-sm cursor-pointer transition-all',
|
||||||
color.fg,
|
'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 : ''}`}
|
||||||
|
>
|
||||||
|
{/* Status accent strip on the left */}
|
||||||
|
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
|
||||||
|
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
|
||||||
|
<span>{p.spot.dx_call}</span>
|
||||||
|
{mode && (
|
||||||
|
<span className="text-[9px] font-normal text-current/70 bg-current/10 rounded px-1 py-px">
|
||||||
|
{mode}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
title={`${s.dx_call} · ${s.freq_khz.toFixed(1)} kHz${s.comment ? ' · ' + s.comment : ''}${s.spotter ? ' · de ' + s.spotter : ''}`}
|
</span>
|
||||||
>
|
</button>
|
||||||
{s.dx_call}
|
);
|
||||||
</button>
|
})}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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 · ◎ = recenter
|
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampCenter(c: number, [lo, hi]: [number, number], zoom: number): number {
|
|
||||||
const halfSpan = (hi - lo) / zoom / 2;
|
|
||||||
return Math.max(lo + halfSpan, Math.min(hi - halfSpan, c));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models';
|
import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models';
|
||||||
|
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
@@ -275,6 +276,20 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
|
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live cluster status updates while Preferences is open — the user can
|
||||||
|
// click Connect/Disconnect inside the modal and see the pills change
|
||||||
|
// without saving + reopening.
|
||||||
|
useEffect(() => {
|
||||||
|
EventsOn('cluster:state', async (st: any) => {
|
||||||
|
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
|
||||||
|
try {
|
||||||
|
const list = await ListClusterServers();
|
||||||
|
setClusterServers((list ?? []) as ClusterServer[]);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
return () => { EventsOff('cluster:state'); };
|
||||||
|
}, []);
|
||||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||||
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
|
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
|
||||||
// the panel as a plain function, not as a JSX element, so any useState
|
// the panel as a plain function, not as a JSX element, so any useState
|
||||||
|
|||||||
+30
-24
@@ -3,44 +3,43 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
/* ===== Cream & warm-orange palette =====
|
/* ===== Warm taupe & burnt-orange palette =====
|
||||||
Background : warm cream (subtle beige, less cold than stone)
|
A step darker than pure cream: the body is a soft taupe so white-ish
|
||||||
Accent : burnt orange (signal-lamp vibe, classic radio amateur)
|
cards lift off the surface, giving real depth without going dark.
|
||||||
Status : muted emerald / amber / rose
|
Borders are more defined; muted surfaces sit between bg and card so
|
||||||
The BandSlotGrid cells keep their emerald+indigo independently — that
|
secondary chrome (toolbars, table headers) reads as quietly recessed.
|
||||||
colour code conveys QSO status semantics, not app branding.
|
|
||||||
*/
|
*/
|
||||||
@theme {
|
@theme {
|
||||||
--color-background: #faf6ed; /* warm cream */
|
--color-background: #e8dfc9; /* warm taupe — deeper than cream */
|
||||||
--color-foreground: #1c1917; /* stone-900 */
|
--color-foreground: #1c1917; /* stone-900 */
|
||||||
|
|
||||||
--color-card: #ffffff;
|
--color-card: #faf6ea; /* lifted off-white cream */
|
||||||
--color-card-foreground: #1c1917;
|
--color-card-foreground: #1c1917;
|
||||||
|
|
||||||
--color-popover: #ffffff;
|
--color-popover: #faf6ea;
|
||||||
--color-popover-foreground: #1c1917;
|
--color-popover-foreground: #1c1917;
|
||||||
|
|
||||||
--color-primary: #c2410c; /* orange-700 — burnt orange */
|
--color-primary: #b8410c; /* burnt orange, slightly deeper */
|
||||||
--color-primary-foreground: #fff7ed;
|
--color-primary-foreground: #fff7ed;
|
||||||
|
|
||||||
--color-secondary: #f5efe0; /* warmer secondary surface */
|
--color-secondary: #ddd2b8; /* a touch under the bg */
|
||||||
--color-secondary-foreground: #1c1917;
|
--color-secondary-foreground: #1c1917;
|
||||||
|
|
||||||
--color-muted: #f5efe0;
|
--color-muted: #ddd2b8; /* toolbars / table headers */
|
||||||
--color-muted-foreground: #57534e; /* stone-600 */
|
--color-muted-foreground: #44403c; /* stone-700 — readable on muted */
|
||||||
|
|
||||||
--color-accent: #ffedd5; /* orange-100 — light cream-orange */
|
--color-accent: #f8d6a9; /* warm amber-cream */
|
||||||
--color-accent-foreground: #9a3412; /* orange-800 */
|
--color-accent-foreground: #7c2d12; /* orange-900 */
|
||||||
|
|
||||||
--color-destructive: #b91c1c; /* red-700, classic brick */
|
--color-destructive: #a51c1c;
|
||||||
--color-destructive-foreground: #ffffff;
|
--color-destructive-foreground: #ffffff;
|
||||||
|
|
||||||
--color-border: #e7dfd0; /* warm beige border */
|
--color-border: #c8b994; /* deeper warm beige border */
|
||||||
--color-input: #e7dfd0;
|
--color-input: #c8b994;
|
||||||
--color-ring: #ea580c; /* orange-600 — focus ring */
|
--color-ring: #d97706; /* amber-600 focus ring */
|
||||||
|
|
||||||
--color-ok: #15803d; /* emerald-700 — slightly deeper */
|
--color-ok: #15803d;
|
||||||
--color-warn: #b45309; /* amber-700 */
|
--color-warn: #b45309;
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
@@ -61,12 +60,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Warm scrollbar */
|
/* Subtle elevation on every Card-styled surface so cards visibly sit on
|
||||||
|
top of the taupe background — paper-on-paper feel. */
|
||||||
|
.bg-card {
|
||||||
|
box-shadow: 0 1px 2px rgba(28, 25, 23, 0.05),
|
||||||
|
0 0 0 1px rgba(28, 25, 23, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warm scrollbar tuned to the deeper bg */
|
||||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #d6cbb1;
|
background: #b8a880;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: 2px solid var(--color-background);
|
border: 2px solid var(--color-background);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover { background: #b3a47d; }
|
::-webkit-scrollbar-thumb:hover { background: #968455; }
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ export namespace main {
|
|||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
|
continent?: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -423,6 +424,7 @@ export namespace main {
|
|||||||
this.band = source["band"];
|
this.band = source["band"];
|
||||||
this.mode = source["mode"];
|
this.mode = source["mode"];
|
||||||
this.country = source["country"];
|
this.country = source["country"];
|
||||||
|
this.continent = source["continent"];
|
||||||
this.status = source["status"];
|
this.status = source["status"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ type ServerConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spot is a single DX spot as parsed from the cluster stream.
|
// Spot is a single DX spot as parsed from the cluster stream.
|
||||||
|
// Country/Continent are filled by the caller (app.go) before the spot
|
||||||
|
// is emitted to the UI, so the table never has empty country cells
|
||||||
|
// flickering in for a few hundred ms.
|
||||||
type Spot struct {
|
type Spot struct {
|
||||||
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
|
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
|
||||||
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
|
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
|
||||||
@@ -47,6 +50,8 @@ type Spot struct {
|
|||||||
Comment string `json:"comment,omitempty"`
|
Comment string `json:"comment,omitempty"`
|
||||||
Locator string `json:"locator,omitempty"` // spotter grid (optional)
|
Locator string `json:"locator,omitempty"` // spotter grid (optional)
|
||||||
TimeUTC string `json:"time_utc,omitempty"`
|
TimeUTC string `json:"time_utc,omitempty"`
|
||||||
|
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
|
||||||
|
Continent string `json:"continent,omitempty"` // 2-letter continent
|
||||||
ReceivedAt time.Time `json:"received_at"`
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
Raw string `json:"raw"`
|
Raw string `json:"raw"`
|
||||||
}
|
}
|
||||||
@@ -92,6 +97,7 @@ type session struct {
|
|||||||
conn net.Conn
|
conn net.Conn
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
doneCh chan struct{}
|
doneCh chan struct{}
|
||||||
|
stopped bool // guards against double-stop on the same session
|
||||||
spotsCnt int
|
spotsCnt int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,11 +168,14 @@ func (m *Manager) StopServer(id int64) {
|
|||||||
if ok {
|
if ok {
|
||||||
delete(m.sessions, id)
|
delete(m.sessions, id)
|
||||||
}
|
}
|
||||||
|
remaining := len(m.sessions)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
fmt.Printf("cluster.StopServer id=%d found=%v remaining=%d\n", id, ok, remaining)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.stop()
|
s.stop()
|
||||||
|
fmt.Printf("cluster.StopServer id=%d stopped successfully\n", id)
|
||||||
m.emitStatus()
|
m.emitStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,10 +239,19 @@ func (s *session) send(cmd string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *session) stop() {
|
func (s *session) stop() {
|
||||||
|
// Critical: do NOT nil out s.stopCh — the supervisor goroutine reads
|
||||||
|
// `<-s.stopCh` in its select. Setting the field to nil would make
|
||||||
|
// `<-nil` block forever, leaving the supervisor stuck on its backoff
|
||||||
|
// timer and then re-dialing → a "deleted" cluster keeps spotting.
|
||||||
|
// We just close() the channel and let the goroutine see the broadcast.
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
if s.stopped {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.stopped = true
|
||||||
stop, done := s.stopCh, s.doneCh
|
stop, done := s.stopCh, s.doneCh
|
||||||
conn := s.conn
|
conn := s.conn
|
||||||
s.stopCh, s.doneCh, s.conn = nil, nil, nil
|
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if conn != nil {
|
if conn != nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
|
|||||||
+28
-14
@@ -832,34 +832,48 @@ type EntitySlot struct {
|
|||||||
Slots map[string]map[string]struct{} // band → modes worked
|
Slots map[string]map[string]struct{} // band → modes worked
|
||||||
}
|
}
|
||||||
|
|
||||||
// EntitySlotMap returns slot data for every QSO grouped by lowercase
|
// EntitySlotMap returns slot data for every QSO, grouping by entity.
|
||||||
// country name (cty.dat-style key). Cheap on a 25k-row table: one
|
//
|
||||||
// scan, no joins. Callers can compare a spot's entity to this map to
|
// `resolveEntity` maps a callsign to its canonical entity name (we use
|
||||||
// decide if it's NEW / NEW SLOT / WORKED.
|
// cty.dat for this). When non-nil, the resolved name wins over the
|
||||||
func (r *Repo) EntitySlotMap(ctx context.Context) (map[string]*EntitySlot, error) {
|
// stored `country` column — that's important because QRZ's "Turkey"
|
||||||
|
// disagrees with cty.dat's "Asiatic Turkey" and the cluster status
|
||||||
|
// comparison would otherwise miss past QSOs. When nil, we fall back to
|
||||||
|
// the stored country (useful for tests).
|
||||||
|
//
|
||||||
|
// One DB scan regardless of input size. Cheap to call per cluster batch.
|
||||||
|
func (r *Repo) EntitySlotMap(ctx context.Context, resolveEntity func(callsign string) string) (map[string]*EntitySlot, error) {
|
||||||
rows, err := r.db.QueryContext(ctx,
|
rows, err := r.db.QueryContext(ctx,
|
||||||
`SELECT lower(country), lower(band), upper(mode) FROM qso
|
`SELECT callsign, lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
||||||
WHERE country IS NOT NULL AND country != ''
|
WHERE band IS NOT NULL AND band != ''
|
||||||
AND band IS NOT NULL AND band != ''
|
AND mode IS NOT NULL AND mode != ''`)
|
||||||
AND mode IS NOT NULL AND mode != ''`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := make(map[string]*EntitySlot, 256)
|
out := make(map[string]*EntitySlot, 256)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var country, band, mode string
|
var call, country, band, mode string
|
||||||
if err := rows.Scan(&country, &band, &mode); err != nil {
|
if err := rows.Scan(&call, &country, &band, &mode); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e, ok := out[country]
|
key := country
|
||||||
|
if resolveEntity != nil {
|
||||||
|
if name := strings.ToLower(strings.TrimSpace(resolveEntity(call))); name != "" {
|
||||||
|
key = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e, ok := out[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
e = &EntitySlot{
|
e = &EntitySlot{
|
||||||
Country: country,
|
Country: key,
|
||||||
Bands: make(map[string]struct{}),
|
Bands: make(map[string]struct{}),
|
||||||
Slots: make(map[string]map[string]struct{}),
|
Slots: make(map[string]map[string]struct{}),
|
||||||
}
|
}
|
||||||
out[country] = e
|
out[key] = e
|
||||||
}
|
}
|
||||||
e.Bands[band] = struct{}{}
|
e.Bands[band] = struct{}{}
|
||||||
bandSlots, ok := e.Slots[band]
|
bandSlots, ok := e.Slots[band]
|
||||||
|
|||||||
Reference in New Issue
Block a user