feat(dxcluster): споты на всех панадаптерах + кнопка DX в блоке RX

Подписи спотов рисовались только на главном пане. Теперь их видит каждый пан,
в чьё окно попадает частота спота.

Оверлей — ЭКЗЕМПЛЯР НА ПАН, а не один общий: кэш полосы подписей ключуется
центром/спаном/шириной, а у панов они свои — общий оверлей пересобирался бы на
каждый пан каждый кадр, то есть ровно то, ради чего кэш и заводился. Живёт в
TPanafallPanel (AttachDXSpots/DetachDXSpots/DXOverlay, Owner=панель), база
спотов по-прежнему одна на всех. FDXSpotOverlay в MainForm стал алиасом на
оверлей пана 0 — как FSpecView для FPan.View: настройки, тема и тик затухания
идут общим циклом по FPans, и главный пан перестал быть особым случаем (иначе
тумблер DX гасил бы подписи только на нём).

★ Version стора глобальна, а окно у пана своё, поэтому в EnsureRendered на
смену версии сначала берётся снимок окна и сверяется его подпись (FNV-1a по
позывному, частоте, моде и метке времени). Совпала — ни пересборки битмапа, ни
перезаливки GL-текстуры: спот, севший на чужой диапазон, до этого пана не
доходит. Без этого цена пересборки множилась бы на число панов. Снимок берётся
один раз и переиспользуется раскладкой — стор второй раз не дёргаем.

Клик по подписи на пане N = QSY слайса ЭТОГО пана (активного, если он здесь,
иначе первого; нет ни одного — создаём в точке) с модой по комментарию
кластера и полосой пресета этой моды. Своего VFO у панов N нет, а ретюнить их
DDC под спот нельзя — увезло бы весь пан. Мода спота → режим вынесена в общую
DXSpotRadioMode для обоих путей.

Низкий пан (грид): полоса подписей и штрихи пропускаются целиком — раньше в
GL-пути полоса легла бы поверх спектра, а штрихи пошли бы снизу вверх.

Кнопка DX переехала из тулбара в блок RX левой панели, сразу после CTUN (ряд
стал четырёхколоночным: CTUN | DX | Channel | BEACON): споты — часть приёмного
вида, а не глобальная команда уровня DISCOVER/START/SETUP. Заодно снят расчёт
SafeLeft, резервировавший под неё место в шапке.

Подписи по умолчанию ВЫКЛЮЧЕНЫ (show_spots: дефолт и фолбэк чтения были True),
состояние переживает перезапуск. Клик по кнопке подливает состояние в открытый
SETUP: там своя копия конфига, и первая же правка любого поля страницы вернула
бы подписи обратно.

fix: Stop клиента кластера больше не держит главный поток. Он делал Terminate +
WaitFor, а поток в этот момент сидит в блокирующем recv и просыпается лишь по
своему кванту (до 1 с), на застрявшем send — до SO_SNDTIMEO. Всё это время
окно висело на выходе и на переподключении после правки настроек. Теперь Stop
рвёт живой сокет SockShutdown (хэндл — зеркало под тем же локом, поток снимает
его ДО close, поэтому shutdown по закрытому fd невозможен), а FormDestroy зовёт
неблокирующий RequestStop первой строкой: поток доживает параллельно с
разборкой радио, и join в конце уже никого не ждёт.

Сборка ewsdr (--ws=qt6) и ewsdrd — ОК. На железе не проверено: доп. паны
требуют подключённого радио.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 18:00:08 +03:00
co-authored by Claude Opus 5
parent a2fdc3a741
commit 17fa965aec
8 changed files with 362 additions and 87 deletions
+51 -7
View File
@@ -67,6 +67,13 @@ type
FStore: TDXSpotStore; // не владеет
// Сокет живого соединения — зеркало под FLock. Нужен ровно для того, чтобы
// Stop() мог сделать SockShutdown и вывести поток из блокирующего recv:
// иначе выход из программы ждал бы до конца кванта recv (и до конца
// таймаута send, если кластер перестал забирать данные). Поток снимает
// зеркало ДО close, поэтому под локом хэндл гарантированно ещё открыт.
FActiveSock: TSocket;
FState: TDXClusterState;
FStatusMsg: string;
FSpotCount: Int64; // сколько спотов принято за сессию
@@ -79,6 +86,7 @@ type
FOutQueue: TStringList; // строки на отправку в кластер
procedure SetState(St: TDXClusterState; const Msg: string);
procedure PublishSock(S: TSocket); // зеркало хэндла для Stop (см. FActiveSock)
public
constructor Create(AStore: TDXSpotStore);
destructor Destroy; override;
@@ -86,7 +94,11 @@ type
procedure Configure(const AHost: string; APort: Integer;
const ALogin, APassword, APostLogin: string);
procedure Start;
procedure Stop;
// Неблокирующая половина Stop: разбудить поток и порвать сокет, не дожидаясь
// его конца. Зовётся в начале выхода из программы, чтобы поток доживал
// ПАРАЛЛЕЛЬНО с разборкой радио, а join в Stop уже никого не ждал.
procedure RequestStop;
procedure Stop; // RequestStop + join
function Running: Boolean;
// Отправить сырую строку в кластер (окно списка: sh/dx, set/filter, …).
@@ -274,6 +286,7 @@ begin
FLogCount := 0;
FLogVer := 0;
FSpotCount := 0;
FActiveSock := SOCK_INVALID;
{$IFDEF MSWINDOWS}
WSAStartup($0202, wsa);
{$ENDIF}
@@ -329,8 +342,7 @@ begin
FThread := TDXClusterThread.Create(Self);
end;
procedure TDXClusterClient.Stop;
var T: TThread;
procedure TDXClusterClient.RequestStop;
begin
// Всё, что не успели отправить, к следующему соединению уже неактуально.
FLock.Enter;
@@ -339,7 +351,28 @@ begin
finally
FLock.Leave;
end;
if FThread = nil then Exit;
FThread.Terminate;
FStopEvent.SetEvent;
// Терминации мало: поток стоит в блокирующем recv и проснулся бы только по
// SO_RCVTIMEO (до DX_RECV_MS), а на застрявшем send — до SO_SNDTIMEO. Всё это
// время join держал бы главный поток, т.е. окно висело бы на выходе и на
// переподключении после правки настроек. SockShutdown рвёт обе стороны
// немедленно (на Linux одного close мало — см. WebUtils.SockShutdown).
// Закрывает сокет по-прежнему сам поток: закрыть чужой хэндл значило бы
// гонку с его же recv.
FLock.Enter;
try
if FActiveSock <> SOCK_INVALID then SockShutdown(FActiveSock);
finally
FLock.Leave;
end;
end;
procedure TDXClusterClient.Stop;
var T: TThread;
begin
RequestStop;
T := FThread;
if T = nil then
begin
@@ -347,10 +380,6 @@ begin
Exit;
end;
FThread := nil;
T.Terminate;
FStopEvent.SetEvent;
// Сокет закрывает сам поток (TDXClusterThread.CloseSock из Execute);
// FStopEvent + Terminate выводят его из ожиданий, а recv рвётся по таймауту.
T.WaitFor;
T.Free;
SetState(dxsOff, '');
@@ -361,6 +390,19 @@ begin
Result := FThread <> nil;
end;
procedure TDXClusterClient.PublishSock(S: TSocket);
// Зеркало хэндла для Stop. Поток зовёт с валидным хэндлом сразу после connect
// и с SOCK_INVALID ПЕРЕД close — так Stop под локом видит либо живой сокет,
// либо ничего, но не закрытый.
begin
FLock.Enter;
try
FActiveSock := S;
finally
FLock.Leave;
end;
end;
procedure TDXClusterClient.SetState(St: TDXClusterState; const Msg: string);
begin
FLock.Enter;
@@ -606,6 +648,7 @@ begin
{$ELSE}
fpSetSockOpt(FSocket, SOL_SOCKET, SO_KEEPALIVE, @One, SizeOf(One));
{$ENDIF}
FOwner.PublishSock(FSocket); // с этого момента Stop может рвать нас shutdown'ом
Result := True;
end;
@@ -613,6 +656,7 @@ procedure TDXClusterThread.CloseSock;
begin
if FSocket <> SOCK_INVALID then
begin
FOwner.PublishSock(SOCK_INVALID); // до close: Stop не должен увидеть мёртвый хэндл
SockShutdown(FSocket);
SockClose(FSocket);
FSocket := SOCK_INVALID;
+92 -24
View File
@@ -12,6 +12,11 @@ unit DXSpotOverlay;
W × FBandH с постоянной альфой (BlendBitmapKey, тот же дворд-блендинг).
Полноэкранный кэш W × H на кадр стоил бы в разы дороже — поэтому штрихи
ниже полосы в кэш НЕ входят.
• Версия стора глобальна, а окно у каждого пана своё, поэтому на смену
версии сначала берётся снимок окна и сверяется его ПОДПИСЬ: спот, севший
на чужой диапазон, до полосы подписей этого пана не доходит. Без этого
каждый пан пересобирал бы кэш (и перезаливал GL-текстуру) на любой спот
откуда угодно — цена, которая множится на число панов.
• Штрихи от полосы до низа спектра рисует вызывающая сторона теми же
примитивами, что и все прочие маркеры: CPU — RawVLine внутри RawBegin/
RawEnd (никакого Canvas на битмапе кадра), GL — DrawLine. Оверлей отдаёт
@@ -77,20 +82,27 @@ type
FKeyLight: Boolean;
FKeyRows: Integer;
FKeyAgeTick: Int64;
FKeyStamp: Int64; // подпись набора, по которому собран кэш
FAgeTick: Int64; // бампается таймером UI — обновить затухание
FRenderVer: Int64; // ++ на каждую пересборку кэша — ключ GL-текстуры
FLayoutTTL: Integer; // горизонт затухания, снят один раз на раскладку
// Снимок окна: берётся при смене вида/версии стора, живёт до пересборки —
// подпись считается по нему же, второй раз стор не дёргаем.
FArr: TDXSpotArray;
FArrN: Integer;
FArrStamp: Int64;
FItems: array of TDXSpotItem;
FItemCount: Integer;
FHidden: Integer; // сколько спотов не влезло в лесенку
function FreqToX(FreqHz: Double; W: Integer): Integer;
function AgeColor(const S: TDXSpot): TColor;
procedure TakeSnapshot;
procedure Layout(C: TCanvas; W: Integer);
procedure DrawSelf(C: TCanvas; W: Integer);
procedure RebuildCache(W: Integer);
function NeedsRebuild(W: Integer): Boolean;
procedure RebuildCache(W: Integer; Ver: Int64);
function ViewKeysChanged(W: Integer): Boolean;
procedure SetMaxRows(V: Integer);
public
constructor Create(AOwner: TComponent); override;
@@ -157,6 +169,13 @@ const
CHIP_PAD_X = 3;
CHIP_GAP = 3; // минимальный зазор между чипами в одном ряду
// Подпись видимого набора (FNV-1a 64). Базис — канонический $CBF29CE4…,
// сложенный из половин: цельным литералом он не влезает в Int64, а
// сравниваем мы подпись только сама с собой, так что важна лишь стабильность.
FNV_BASIS_HI = QWord($CBF29CE4);
FNV_BASIS_LO = QWord($84222325);
FNV_PRIME: QWord = $00000100000001B3;
function Mix(A, B: TColor; Num, Den: Integer): TColor;
// A→B на Num/Den (0 = A, Den = B).
var ra, ga, ba, rb, gb, bb: Integer;
@@ -190,6 +209,9 @@ begin
FCacheDirty := True;
FKeyCenter := -1; FKeySpan := -1; FKeyW := -1; FKeyVersion := -1;
FKeyEnabled := False; FKeyLight := False; FKeyRows := -1; FKeyAgeTick := -1;
FKeyStamp := 0;
FArrN := 0;
FArrStamp := 0;
FAgeTick := 0;
FRenderVer := 0;
end;
@@ -293,28 +315,55 @@ end;
{ ── Раскладка лесенки ────────────────────────────────────────────────────── }
procedure TDXSpotOverlay.Layout(C: TCanvas; W: Integer);
// Споты приходят из стора отсортированными по частоте; каждому даём ПЕРВЫЙ
// ряд сверху, где подпись не наезжает на предыдущую. Не нашлось ряда —
// спот скрыт (считаем в FHidden), в лесенке дырок не оставляем.
procedure TDXSpotOverlay.TakeSnapshot;
// Снимок видимого окна + его подпись. Подпись складывает всё, от чего зависит
// картинка полосы: позывной, частоту, моду и метку времени (она правит цвет
// затухания и обновляется при пере-споте). Совпала с прошлой — пересобирать
// нечего, какой бы ни стала версия стора.
var
Arr: TDXSpotArray;
N, i, r, TW, ChipW, ChipX, Row: Integer;
RowRight: array[0..DXSPOT_MAX_ROWS-1] of Integer;
i, k: Integer;
H: QWord;
LoHz, HiHz: Double;
begin
FItemCount := 0;
FHidden := 0;
if (FStore = nil) or (FSpanHz <= 0) or (W <= 0) then Exit;
FArrN := 0;
SetLength(FArr, 0);
FArrStamp := 0;
if (FStore = nil) or (FSpanHz <= 0) then Exit;
// Горизонт затухания: явный фильтр по возрасту, иначе TTL стора (чтение
// под локом — поэтому один раз на раскладку, а не на каждый спот).
// под локом — поэтому один раз на снимок, а не на каждый спот).
FLayoutTTL := FMaxAgeMin;
if FLayoutTTL <= 0 then FLayoutTTL := FStore.TTLMinutes;
LoHz := FCenterFreq - FSpanHz / 2;
HiHz := FCenterFreq + FSpanHz / 2;
N := FStore.SnapshotRange(LoHz, HiHz, FModes, FMaxAgeMin, Arr);
FArrN := FStore.SnapshotRange(LoHz, HiHz, FModes, FMaxAgeMin, FArr);
H := (FNV_BASIS_HI shl 32) or FNV_BASIS_LO; // FNV-1a 64
for i := 0 to FArrN - 1 do
begin
for k := 1 to Length(FArr[i].Call) do
H := (H xor QWord(Ord(FArr[i].Call[k]))) * FNV_PRIME;
H := (H xor QWord(Round(FArr[i].FreqHz))) * FNV_PRIME;
H := (H xor QWord(Ord(FArr[i].Mode))) * FNV_PRIME;
H := (H xor QWord(Round(FArr[i].Stamp * 86400))) * FNV_PRIME;
end;
FArrStamp := Int64(H);
end;
procedure TDXSpotOverlay.Layout(C: TCanvas; W: Integer);
// Споты приходят из стора отсортированными по частоте; каждому даём ПЕРВЫЙ
// ряд сверху, где подпись не наезжает на предыдущую. Не нашлось ряда —
// спот скрыт (считаем в FHidden), в лесенке дырок не оставляем. Работаем по
// снимку FArr — его взял TakeSnapshot перед пересборкой.
var
N, i, r, TW, ChipW, ChipX, Row: Integer;
RowRight: array[0..DXSPOT_MAX_ROWS-1] of Integer;
begin
FItemCount := 0;
FHidden := 0;
if (FStore = nil) or (FSpanHz <= 0) or (W <= 0) then Exit;
N := FArrN;
if N = 0 then Exit;
for r := 0 to FMaxRows - 1 do RowRight[r] := -CHIP_GAP - 1;
@@ -322,9 +371,9 @@ begin
for i := 0 to N - 1 do
begin
TW := C.TextWidth(Arr[i].Call);
TW := C.TextWidth(FArr[i].Call);
ChipW := TW + CHIP_PAD_X * 2;
ChipX := FreqToX(Arr[i].FreqHz, W) - ChipW div 2;
ChipX := FreqToX(FArr[i].FreqHz, W) - ChipW div 2;
if ChipX < 0 then ChipX := 0;
if ChipX + ChipW > W then ChipX := W - ChipW;
if ChipX < 0 then Continue; // подпись шире окна — не рисуем
@@ -343,11 +392,11 @@ begin
end;
RowRight[Row] := ChipX + ChipW;
FItems[FItemCount].X := FreqToX(Arr[i].FreqHz, W);
FItems[FItemCount].X := FreqToX(FArr[i].FreqHz, W);
FItems[FItemCount].Chip := Rect(ChipX, Row * FRowH + 2,
ChipX + ChipW, Row * FRowH + FRowH);
FItems[FItemCount].Color := AgeColor(Arr[i]);
FItems[FItemCount].Spot := Arr[i];
FItems[FItemCount].Color := AgeColor(FArr[i]);
FItems[FItemCount].Spot := FArr[i];
Inc(FItemCount);
end;
SetLength(FItems, FItemCount);
@@ -404,7 +453,9 @@ end;
{ ── Кэш ──────────────────────────────────────────────────────────────────── }
function TDXSpotOverlay.NeedsRebuild(W: Integer): Boolean;
function TDXSpotOverlay.ViewKeysChanged(W: Integer): Boolean;
// Всё, что делает картинку заведомо другой, КРОМЕ версии стора: её разбирает
// EnsureRendered отдельно (версия глобальна, окно — наше).
var HalfPxHz: Double;
begin
if FStore = nil then Exit(False);
@@ -414,7 +465,6 @@ begin
if HalfPxHz < 1.0 then HalfPxHz := 1.0;
Result := FCacheDirty or (FCacheW <> W) or (FKeyW <> W)
or (FKeyEnabled <> FEnabled)
or (FKeyVersion <> FStore.Version)
or (FKeyLight <> FLightTheme)
or (FKeyRows <> FMaxRows)
or (FKeyAgeTick <> FAgeTick)
@@ -422,7 +472,9 @@ begin
or (Abs(FKeySpan - FSpanHz) >= HalfPxHz);
end;
procedure TDXSpotOverlay.RebuildCache(W: Integer);
procedure TDXSpotOverlay.RebuildCache(W: Integer; Ver: Int64);
// Снимок (TakeSnapshot) к этому моменту уже взят вызывающим — здесь только
// рисование и запись ключей.
begin
if (W <= 0) or (FStore = nil) then
begin
@@ -439,7 +491,8 @@ begin
FKeySpan := FSpanHz;
FKeyW := W;
FKeyEnabled := FEnabled;
FKeyVersion := FStore.Version;
FKeyVersion := Ver;
FKeyStamp := FArrStamp;
FKeyLight := FLightTheme;
FKeyRows := FMaxRows;
FKeyAgeTick := FAgeTick;
@@ -449,9 +502,24 @@ end;
{ ── Рисование ────────────────────────────────────────────────────────────── }
procedure TDXSpotOverlay.EnsureRendered(W: Integer);
var Ver: Int64;
begin
if (not Active) or (W <= 0) then Exit;
if NeedsRebuild(W) then RebuildCache(W);
Ver := FStore.Version;
if ViewKeysChanged(W) then
begin
TakeSnapshot;
RebuildCache(W, Ver);
Exit;
end;
if FKeyVersion = Ver then Exit; // в сторе с прошлого кадра ничего
// Стор правили — но, может, не в нашем окне: снимок с подписью дешевле
// пересборки полосы (и, в GL, перезаливки текстуры). Версию запоминаем в
// любом случае: этот вопрос уже разобран.
FKeyVersion := Ver;
TakeSnapshot;
if FArrStamp = FKeyStamp then Exit;
RebuildCache(W, Ver);
end;
procedure TDXSpotOverlay.DrawOverlay(Target: TBitmap; W, H: Integer);
+172 -48
View File
@@ -275,7 +275,6 @@ type
BtnDiscover: TFlatButton;
BtnStartStop: TFlatButton;
BtnSettings: TFlatButton;
BtnDXCluster: TFlatButton; // ЛКМ — споты на спектре, ПКМ — окно кластера
// ---- Left panel ----
PanelLeft: TPanel;
@@ -373,6 +372,8 @@ type
LblAtt: TLabel;
LblAttVal: TLabel;
TrkAtt: TFlatSlider;
// Ряд CTUN | DX | Channel | BEACON.
BtnDXCluster: TFlatButton; // ЛКМ — споты на спектре, ПКМ — окно кластера
BtnChannels: TFlatButton;
FChannelsDropDown: TFlatDropDown;
// TX-профиль: кнопка с именем активного (блок TX, ряд DUP) + список.
@@ -514,6 +515,9 @@ type
procedure InitDXCluster; // стор+клиент+оверлей, чтение настроек
procedure ServiceDXCluster; // тик затухания спотов (редкий)
procedure ApplyDXClusterSettings(const D: TDXClusterSettings);
procedure AttachPanDXSpots(P: TPanafallPanel); // свой оверлей спотов на пан
procedure ApplyDXOverlayCfg(O: TDXSpotOverlay); // настройки вида → один оверлей
procedure DXTuneSpotOnPan(P: TPanafallPanel; FreqHz: Double; Mode: TDXMode);
procedure UpdateDXClusterButton;
procedure BtnDXClusterClick(Sender: TObject);
procedure BtnDXClusterMouseDown(Sender: TObject; Button: TMouseButton;
@@ -1383,9 +1387,14 @@ begin
end;
procedure TMainForm.FormDestroy(Sender: TObject);
var i: Integer;
begin
FMeterTimer.Enabled := False;
FSpectrumTimer.Enabled := False;
// Поток кластера будим ПЕРВЫМ делом, не дожидаясь его: пока идёт (небыстрая)
// разборка радио, он успевает выйти сам, и join в FreeAndNil(FDXClient) ниже
// уже никого не ждёт. Иначе окно висело бы на выходе.
if FDXClient <> nil then FDXClient.RequestStop;
// Грациозный стоп радио (Run=0) до освобождения; сами движки закрывает и
// освобождает TRadioController.Destroy (FreeEngines) ниже.
if FController.FNetwork.Running then
@@ -1401,13 +1410,18 @@ begin
PbPanZoom := nil;
// DX-кластер: незакоммиченную правку SETUP дописываем в конфиг (соединение
// при этом не трогаем — программа закрывается), гасим сетевой поток (его
// Stop делает shutdown сокета и ждёт поток), потом базу спотововерлей
// к этому моменту уже мёртв вместе с view пана.
// Stop делает shutdown сокета и ждёт поток), потом базу спотов. Оверлеи
// панов живут до гибели своих панов (ниже) и на общую базу ссылаются —
// поэтому сначала отвязываем их: после Detach оверлей неактивен и к стору
// не ходит, даже если между этими строками прилетит перерисовка.
if FDXPending then
begin
FDXPending := False;
FController.FSettings.SaveDXClusterSettings(FDXPendingCfg);
end;
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then FPans[i].DetachDXSpots;
FDXSpotOverlay := nil;
FreeAndNil(FDXClient);
FreeAndNil(FDXStore);
DestroyAllExtraPans; // доп. паны (DDC/движок/панели) — до пана 0
@@ -2013,10 +2027,8 @@ begin
BtnDiscover := MakeBtn(PanelToolbar, 'DISCOVER', X, 3, 80, BTN_H, BtnDiscoverClick);
BtnStartStop := MakeBtn(PanelToolbar, 'START', X+84, 3, 76, BTN_H, BtnStartStopClick);
BtnSettings := MakeBtn(PanelToolbar, 'SETUP', X+164, 3, 66, BTN_H, BtnSettingsClick);
// DX — споты кластера: ЛКМ включает/выключает подписи на спектре, ПКМ
// открывает окно списка (та же семантика, что у BEACON → окно констелляции).
BtnDXCluster := MakeBtn(PanelToolbar, 'DX', X+234, 3, 46, BTN_H, BtnDXClusterClick);
BtnDXCluster.OnMouseDown := BtnDXClusterMouseDown;
// DX живёт в блоке RX левой панели, рядом с CTUN (см. ниже): споты — часть
// приёмного вида, а не глобальная команда уровня DISCOVER/START/SETUP.
// DISCOVER — синеватый акцент
BtnDiscover.ClrNorm := TColor($00101828);
@@ -2272,7 +2284,7 @@ begin
Inc(Y, 100);
// RX block: CTUN + VOL + NR/NB/SNB/ANF/MUTE
// RX block: CTUN + DX + Channel + BEACON, VOL, NR/NB/SNB/ANF/MUTE
PanelRXBlock := TPanel.Create(Self);
PanelRXBlock.Parent := PanelLeft;
PanelRXBlock.SetBounds(0, Y, LEFT_W, RX_BLOCK_H_ATT);
@@ -2280,15 +2292,23 @@ begin
MakeLbl(PanelRXBlock, 'RX', 4, 2);
BtnCTun := MakeBtn(PanelRXBlock, 'CTUN',
LeftPanelButtonLeft(LEFT_W, 3, 0), 20,
LeftPanelButtonWidth(LEFT_W, 3, 0),
LeftPanelButtonLeft(LEFT_W, 4, 0), 20,
LeftPanelButtonWidth(LEFT_W, 4, 0),
BTN_H, BtnCTunClick);
BtnCTun.Tag := 0;
StyleButton(BtnCTun, FController.FCTun);
// DX — споты кластера: ЛКМ включает/выключает подписи на спектре, ПКМ
// открывает окно списка (та же семантика, что у BEACON → окно констелляции).
BtnDXCluster := MakeBtn(PanelRXBlock, 'DX',
LeftPanelButtonLeft(LEFT_W, 4, 1), 20,
LeftPanelButtonWidth(LEFT_W, 4, 1),
BTN_H, BtnDXClusterClick);
BtnDXCluster.OnMouseDown := BtnDXClusterMouseDown;
BtnChannels := MakeBtn(PanelRXBlock, 'Channel',
LeftPanelButtonLeft(LEFT_W, 3, 1), 20,
LeftPanelButtonWidth(LEFT_W, 3, 1),
LeftPanelButtonLeft(LEFT_W, 4, 2), 20,
LeftPanelButtonWidth(LEFT_W, 4, 2),
BTN_H, BtnChannelsClick);
BtnChannels.OnMouseDown := BtnChannelsMouseDown;
StyleButton(BtnChannels, False);
@@ -2298,11 +2318,11 @@ begin
FChannelsDropDown.OnSelect := OnChannelsDropDownSelect;
RefreshChannelsDropDown;
// QO-100 beacon lock — 3-я колонка ряда. Видима только в XVTR-режиме
// QO-100 beacon lock — 4-я колонка ряда. Видима только в XVTR-режиме
// (см. рендер rfXvtr ниже). Подпись/подсветка обновляются по rfBeaconLock.
BtnBeacon := MakeBtn(PanelRXBlock, 'BEACON',
LeftPanelButtonLeft(LEFT_W, 3, 2), 20,
LeftPanelButtonWidth(LEFT_W, 3, 2),
LeftPanelButtonLeft(LEFT_W, 4, 3), 20,
LeftPanelButtonWidth(LEFT_W, 4, 3),
BTN_H, BtnBeaconClick);
BtnBeacon.Visible := False;
BtnBeacon.OnMouseDown := BtnBeaconMouseDown; // ПКМ → декодер + окно констелляции
@@ -2807,9 +2827,6 @@ begin
SafeLeft := MARGIN;
if BtnSettings <> nil then
SafeLeft := BtnSettings.Left + BtnSettings.Width + SETTINGS_GAP;
// Кнопка DX стоит правее SETUP — группа VFO начинается уже за ней.
if (BtnDXCluster <> nil) and BtnDXCluster.Visible then
SafeLeft := Max(SafeLeft, BtnDXCluster.Left + BtnDXCluster.Width + SETTINGS_GAP);
SafeRight := ClientWidth - MARGIN;
if PanelLeft <> nil then
begin
@@ -3348,9 +3365,6 @@ begin
BtnStartStop.ClrTextAct:= T.TbStartText;
BtnStartStop.Invalidate;
StyleButton(BtnSettings, False);
// DX — тумблер подписей спотов: состояние берём из самой кнопки, как у
// прочих тумблеров ниже (BtnCTun/BtnBeacon).
if BtnDXCluster <> nil then StyleButton(BtnDXCluster, BtnDXCluster.Active);
// Обычные кнопки — все массивы
for i := 0 to BAND_COUNT-1 do StyleButton(BtnBand[i], BtnBand[i].Active);
@@ -3366,6 +3380,8 @@ begin
StyleButton(Btn2Ton, Btn2Ton.Active);
StyleButton(BtnRxMute, BtnRxMute.Active);
StyleButton(BtnBeacon, BtnBeacon.Active);
// DX — тумблер подписей спотов, состояние берём из самой кнопки (сосед CTUN).
if BtnDXCluster <> nil then StyleButton(BtnDXCluster, BtnDXCluster.Active);
if BtnFMSQ <> nil then StyleButton(BtnFMSQ, BtnFMSQ.Active);
if TrkFMSQ <> nil then DS(TrkFMSQ);
if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, BtnFMCTCSS.Active);
@@ -3387,6 +3403,9 @@ begin
if FPans[i] <> nil then
begin
FPans[i].View.SetTheme(T);
// Палитра подписей спотов парная под тему — у каждого пана свой оверлей.
if FPans[i].DXOverlay <> nil then
FPans[i].DXOverlay.SetLightTheme(FLightTheme);
if FPans[i].PbSpectrum is TPaintBox then
TPaintBox(FPans[i].PbSpectrum).Color := T.BG;
if FPans[i].PbWaterfall is TPaintBox then
@@ -3502,7 +3521,7 @@ begin
if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).ApplyTheme(T);
if FBeaconScopeForm <> nil then FBeaconScopeForm.ApplyTheme(T);
if FDXClusterForm <> nil then FDXClusterForm.ApplyTheme(T);
if FDXSpotOverlay <> nil then FDXSpotOverlay.SetLightTheme(FLightTheme);
// Оверлеи спотов перекрашены в ApplyDarkTheme — общим циклом по FPans.
FController.FSettings.SaveTheme(V);
FController.FSettings.Save;
if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
@@ -5024,11 +5043,13 @@ begin
FDXStore := TDXSpotStore.Create;
FDXClient := TDXClusterClient.Create(FDXStore);
FDXSpotOverlay := TDXSpotOverlay.Create(Self);
FDXSpotOverlay.Attach(FDXStore);
// Оверлей пана 0 живёт в самом пане, как и у панов N; FDXSpotOverlay — только
// алиас на него (как FSpecView для FPan.View), чтобы главный пан не был
// особым случаем в настройках/теме/тике.
FPan.AttachDXSpots(FDXStore);
FDXSpotOverlay := FPan.DXOverlay;
FDXSpotOverlay.SetLightTheme(FLightTheme);
FDXSpotOverlay.SetView(FController.FCenterFreq, FController.FSpanHz);
FSpecView.DXSpotOverlay := FDXSpotOverlay;
FDXAgeTickAt := Now;
FController.FSettings.LoadDXClusterSettings(FDXCfg);
@@ -5045,20 +5066,47 @@ procedure TMainForm.ApplyDXClusterSettings(const D: TDXClusterSettings);
// трогает соединение, откладывается до CommitDXClusterSettings: набирая «30» в
// поле TTL, пользователь на миг проходит через «3», а это выбросило бы из базы
// все споты старше трёх минут — необратимо.
var i: Integer;
begin
FDXCfg := D;
if FDXSpotOverlay <> nil then
// Настройки общие для всех панов: споты рисуются на каждом, где частота
// попадает в его окно.
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then
begin
FDXSpotOverlay.MaxRows := D.MaxRows;
FDXSpotOverlay.SetOwnCall(D.Login);
FDXSpotOverlay.SetFilters(DXModeSetFromMask(D.ModeMask), D.TTLMinutes);
FDXSpotOverlay.SetEnabled(D.ShowSpots);
FDXSpotOverlay.Invalidate;
ApplyDXOverlayCfg(FPans[i].DXOverlay);
if i > 0 then MarkPanDirty(FPans[i]);
end;
UpdateDXClusterButton;
FSpectrumDirty := True;
end;
procedure TMainForm.ApplyDXOverlayCfg(O: TDXSpotOverlay);
// Вид одного оверлея по текущему FDXCfg (сеттеры сами гасят «то же самое»).
begin
if O = nil then Exit;
O.MaxRows := FDXCfg.MaxRows;
O.SetOwnCall(FDXCfg.Login);
O.SetFilters(DXModeSetFromMask(FDXCfg.ModeMask), FDXCfg.TTLMinutes);
O.SetEnabled(FDXCfg.ShowSpots);
O.Invalidate;
end;
procedure TMainForm.AttachPanDXSpots(P: TPanafallPanel);
// Доп. пан получает СВОЙ оверлей на общей базе спотов: кэш полосы подписей
// ключуется центром/спаном/шириной пана, поэтому один экземпляр на всех
// пересобирался бы по кругу на каждый пан каждый кадр — ровно то, ради чего
// кэш и заводился.
var VC, VS: Double;
begin
if (P = nil) or (FDXStore = nil) then Exit;
P.AttachDXSpots(FDXStore);
ApplyDXOverlayCfg(P.DXOverlay);
P.DXOverlay.SetLightTheme(FLightTheme);
PanViewWindow(P, VC, VS);
P.DXOverlay.SetView(VC, VS);
end;
procedure TMainForm.ApplyDXConnection(const D: TDXClusterSettings);
// Соединение: конфиг в клиент + поднять/положить/переподнять. Поток снимает
// конфиг один раз на сессию, поэтому смена адреса/учётки требует рестарта —
@@ -5114,6 +5162,7 @@ procedure TMainForm.ServiceDXCluster;
const
DX_AGE_TICK_SEC = 30;
DX_SETTINGS_QUIET_MS = 1200; // тишина в SETUP, после которой коммитим
var i: Integer;
begin
// Отложенный коммит настроек: пользователь перестал печатать в SETUP.
if FDXPending and (MilliSecondsBetween(Now, FDXPendingAt) >= DX_SETTINGS_QUIET_MS) then
@@ -5123,7 +5172,13 @@ begin
if SecondsBetween(Now, FDXAgeTickAt) < DX_AGE_TICK_SEC then Exit;
FDXAgeTickAt := Now;
if not FDXSpotOverlay.Active then Exit;
FDXSpotOverlay.TickAge;
// Тик получают оверлеи всех панов: возраст спота от пана не зависит.
for i := 0 to MAX_PANS - 1 do
if (FPans[i] <> nil) and (FPans[i].DXOverlay <> nil) then
begin
FPans[i].DXOverlay.TickAge;
if i > 0 then MarkPanDirty(FPans[i]);
end;
FSpectrumDirty := True;
end;
@@ -5136,14 +5191,18 @@ end;
procedure TMainForm.BtnDXClusterClick(Sender: TObject);
// ЛКМ — показывать/не показывать подписи спотов на спектре (настройка живёт
// в конфиге, чтобы состояние пережило перезапуск).
// в конфиге, чтобы состояние пережило перезапуск). Кнопка одна на все паны:
// применяем через общий Apply, иначе тумблер гасил бы только главный.
begin
if FDXSpotOverlay = nil then Exit;
FDXCfg.ShowSpots := not FDXCfg.ShowSpots;
FDXSpotOverlay.SetEnabled(FDXCfg.ShowSpots);
ApplyDXClusterSettings(FDXCfg);
FController.FSettings.SaveDXClusterSettings(FDXCfg);
UpdateDXClusterButton;
FSpectrumDirty := True;
// Открытый SETUP держит СВОЮ копию конфига: без синхронизации галка «Show
// spots» осталась бы старой, и первая же правка любого поля на этой странице
// вернула бы подписи обратно.
if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
TSettingsForm(FSettingsForm).LoadDXClusterSettings(FDXCfg);
end;
procedure TMainForm.BtnDXClusterMouseDown(Sender: TObject; Button: TMouseButton;
@@ -5164,27 +5223,78 @@ begin
else FDXClusterForm.Show;
end;
function DXSpotRadioMode(FreqHz: Double; Mode: TDXMode): Integer;
// Мода спота → режим приёмника. −1 = однозначно не определяется (dxmUnknown):
// текущую в этом случае не трогаем — гадать хуже, чем не менять.
begin
Result := -1;
case Mode of
dxmCW: Result := MODE_CWU;
// Боковая по общепринятому правилу: ниже 10 МГц — LSB, выше — USB.
dxmSSB: if FreqHz < 10000000.0 then Result := MODE_LSB else Result := MODE_USB;
dxmFT8, dxmFT4, dxmDigi, dxmRTTY, dxmPSK: Result := MODE_DIGU;
dxmSSTV: Result := MODE_USB;
dxmFM: Result := MODE_FM;
end;
end;
procedure TMainForm.OnDXTuneSpot(FreqHz: Double; Mode: TDXMode);
// QSY по споту: клик по подписи на спектре или двойной клик в окне списка.
// Мода ставится только когда она однозначна из комментария кластера; при
// dxmUnknown текущую не трогаем — гадать хуже, чем не менять.
// QSY по споту: клик по подписи на главном пане или двойной клик в окне списка.
var NewMode: Integer;
begin
if FreqHz <= 0 then Exit;
ApplyVfoA(Round(FreqHz));
NewMode := -1;
case Mode of
dxmCW: NewMode := MODE_CWU;
// Боковая по общепринятому правилу: ниже 10 МГц — LSB, выше — USB.
dxmSSB: if FreqHz < 10000000.0 then NewMode := MODE_LSB else NewMode := MODE_USB;
dxmFT8, dxmFT4, dxmDigi, dxmRTTY, dxmPSK: NewMode := MODE_DIGU;
dxmSSTV: NewMode := MODE_USB;
dxmFM: NewMode := MODE_FM;
end;
NewMode := DXSpotRadioMode(FreqHz, Mode);
if (NewMode >= 0) and (NewMode <> FController.FMode) then
FController.SetMode(NewMode);
end;
procedure TMainForm.DXTuneSpotOnPan(P: TPanafallPanel; FreqHz: Double;
Mode: TDXMode);
// QSY по споту на доп. пане. Цель — слайс ЭТОГО пана (активный, если он здесь,
// иначе первый; нет ни одного — создаём в точке), ровно как у клика по фону
// пана: своего VFO у панов N нет, а ретюнить их DDC под спот нельзя — это
// увезло бы весь пан.
var
Id, NewMode: Integer;
Hz: Int64;
S: TCtrlSlice;
begin
if (P = nil) or (FreqHz <= 0) then Exit;
Hz := Round(FreqHz);
// Спот видим в окне пана, но окно может быть шире полосы захвата (зум) —
// слайс вне capture не поставить.
if not FController.SliceFitsCapture(Hz, P.PanId) then Exit;
if (FActiveSliceId <> 0) and FController.GetSlice(FActiveSliceId, S) and
(S.PanId = P.PanId) then
Id := FActiveSliceId
else
Id := P.FirstSliceId;
NewMode := DXSpotRadioMode(FreqHz, Mode);
if Id = 0 then
begin
AddSliceAtFreqPan(P, Hz); // ставит FActiveSliceId
Id := FActiveSliceId;
if Id = 0 then Exit;
end
else
FController.SetSliceTarget(Id, Hz);
// Режим слайса — с полосой пресета ЭТОГО режима: тащить полосу с главного
// приёмника (как делает AddSliceAtFreqPan для нового слайса) значило бы
// слушать телеграф в 2.7 кГц.
if NewMode >= 0 then
FController.SetSliceModeBW(Id, NewMode,
FController.FilterBWFor(NewMode, FilterDefIdx(NewMode)));
FActiveSliceId := Id;
P.PushSliceFlagState(Id);
P.LayoutFlags;
MarkPanDirty(P);
end;
procedure TMainForm.UpdateBandPlanOverlay;
// Полоска бэндплана видна только в QO-100 (Pluto + full-duplex транспондер).
begin
@@ -6756,6 +6866,9 @@ begin
FController.GetPanViewWindow(P.PanId, VC, VS);
P.View.CenterFreq := VC;
P.View.SpanHz := VS;
// Подписи спотов пана следуют за его окном (SetView сам отсекает неизменное
// → пересборки кэша на каждый кадр нет).
if P.DXOverlay <> nil then P.DXOverlay.SetView(VC, VS);
// Сетка каналов по шагу FM — как на главном пане, но по режиму слайсов
// ЭТОГО пана (у доп. панов нет своего FMode).
if FController.PanHasFMSlice(P.PanId) then
@@ -7407,6 +7520,8 @@ begin
P.View.VfoA := -1e15;
P.View.VfoB := -1e15;
P.View.TXFreq := -1e15;
// Подписи DX-спотов: свой оверлей на общей базе, настройки/тема — общие.
AttachPanDXSpots(P);
// Сплиттер над паном.
Split := TPanel.Create(Self);
@@ -7788,6 +7903,7 @@ var
VC, VS: Double;
Id, W: Integer;
IsSpec: Boolean;
DXSpot: TDXSpot;
begin
P := PanFromSender(Sender);
if P = nil then Exit;
@@ -7799,6 +7915,14 @@ begin
P.ProcessPendingSliceClose;
Exit;
end;
// ЛКМ по подписи DX-спота на пане — QSY слайса ЭТОГО пана (у панов N нет
// VFO A). Как на главном: только в полосе подписей, ниже поведение прежнее.
if IsSpec and (Button = mbLeft) and (P.DXOverlay <> nil) and
P.DXOverlay.SpotAtPixel(X, Y, DXSpot) then
begin
DXTuneSpotOnPan(P, DXSpot.FreqHz, DXSpot.Mode);
Exit;
end;
W := TControl(Sender).Width;
if W <= 0 then Exit;
if (Button = mbLeft) and (ssCtrl in Shift) then
+28
View File
@@ -35,6 +35,7 @@ uses
LCLIntf, LCLType,
OpenGLContextEx,
AppTheme, WDSPEngine, RadioController, DMRDecoder, VfoOverlay,
DXSpotStore, DXSpotOverlay,
SpectrumView, SpectrumViewOpengl, PanZoomBar, FlatButton, DpiUtils;
type
@@ -83,6 +84,10 @@ type
FSliceFlags: array of TVfoOverlay; // флаги B+ (Owner=Self)
FActiveFlag: TVfoOverlay; // чьё событие сейчас обрабатывается
FPendingSliceClose: Integer; // SliceId к удалению после мыши (0=нет)
// Подписи DX-спотов на спектре ЭТОГО пана. Экземпляр свой на каждый пан:
// кэш полосы подписей ключуется центром/спаном/шириной, а у панов они свои
// — общий оверлей пересобирался бы на каждый пан каждый кадр.
FDXOverlay: TDXSpotOverlay; // Owner=Self, база спотов общая
FMainFlagPinned: Boolean; // True = левая панель хозяина скрыта
FMainFlagForced: Boolean; // True = есть доп. панадаптеры
FLastSliceMeterMs: QWord; // троттлинг S-метра слайсов (~10 Гц)
@@ -268,6 +273,14 @@ type
// полный пересчёт (у него wideband/S-метр/оверлеи).
property OnSplitterMoved: TNotifyEvent read FOnSplitterMoved write FOnSplitterMoved;
// ---- Подписи DX-спотов ----
// Своя копия оверлея на пан; база спотов (стор) — общая, её владелец хозяин.
// Вид (центр/спан) пана заливает хозяин, как и прочие параметры вьюхи.
procedure AttachDXSpots(AStore: TDXSpotStore);
// Отвязать от базы (хозяин освобождает стор раньше панов).
procedure DetachDXSpots;
property DXOverlay: TDXSpotOverlay read FDXOverlay;
// ---- Флаги слайсов ----
// Главный флаг (слайс A) создаёт хозяин (проводка событий у него) и отдаёт
// сюда; панель подключает его к view и включает в раскладку/диспетчер.
@@ -1108,6 +1121,21 @@ begin
FView.VfoOverlay := O;
end;
procedure TPanafallPanel.AttachDXSpots(AStore: TDXSpotStore);
begin
if FDXOverlay = nil then
begin
FDXOverlay := TDXSpotOverlay.Create(Self);
FView.DXSpotOverlay := FDXOverlay;
end;
FDXOverlay.Attach(AStore);
end;
procedure TPanafallPanel.DetachDXSpots;
begin
if FDXOverlay <> nil then FDXOverlay.Attach(nil);
end;
function TPanafallPanel.CurSliceId: Integer;
begin
if Assigned(FActiveFlag) then Result := FActiveFlag.SliceId else Result := 0;
+2 -2
View File
@@ -2898,7 +2898,7 @@ end;
class procedure TSettingsManager.DefaultDXCluster(out D: TDXClusterSettings);
begin
D.Enabled := False; // без позывного подключаться всё равно нечем
D.ShowSpots := True;
D.ShowSpots := False; // подписи включает пользователь кнопкой DX
D.Host := 'cluster.dxfun.com';
D.Port := 8000;
D.Login := '';
@@ -2916,7 +2916,7 @@ begin
if FRoot.Find('dxcluster') = nil then Exit;
O := EnsureObj(FRoot, 'dxcluster');
D.Enabled := JB(O, 'enabled', False);
D.ShowSpots := JB(O, 'show_spots', True);
D.ShowSpots := JB(O, 'show_spots', False);
D.Host := JS(O, 'host', 'cluster.dxfun.com');
D.Port := EnsureRange(JI(O, 'port', 8000), 1, 65535);
D.Login := JS(O, 'login', '');
+3
View File
@@ -683,6 +683,9 @@ var
Col: TColor;
begin
if (FDXSpotOverlay = nil) or (not FDXSpotOverlay.Active) then Exit;
// Низкий пан (сетка панов): полоса подписей в него не влезла и не рисуется —
// тогда и штрихам не от чего идти.
if H <= FDXSpotOverlay.BandHeight + 2 then Exit;
for i := 0 to FDXSpotOverlay.TickCount - 1 do
begin
FDXSpotOverlay.Tick(i, X, Col);
+7 -1
View File
@@ -842,6 +842,9 @@ begin
if (FDXSpotOverlay = nil) or (not FDXSpotOverlay.Active) then Exit;
FDXSpotOverlay.EnsureRendered(W);
Y0 := FDXSpotOverlay.BandHeight;
// Низкий пан: полоса подписей не влезла (её текстуру DrawCachedOverlays тоже
// пропускает) — штрихам не от чего идти.
if H <= Y0 + 2 then Exit;
for i := 0 to FDXSpotOverlay.TickCount - 1 do
begin
FDXSpotOverlay.Tick(i, X, Col);
@@ -980,7 +983,10 @@ begin
// Полоса подписей DX-спотов — вверху спектра. Тот же битмап, что в CPU-пути,
// заливается текстурой ТОЛЬКО при dirty (новый спот меняет CacheDirty
// оверлея, вид — центр/спан/ширину).
if Assigned(FDXSpotOverlay) and FDXSpotOverlay.Active then
// Низкому пану полоса подписей не по росту — пропускаем её целиком (тот же
// гейт, что в CPU-пути DrawOverlay).
if Assigned(FDXSpotOverlay) and FDXSpotOverlay.Active and
(H > FDXSpotOverlay.BandHeight) then
begin
if FOverlayDirty or FDXOverlayDirty or FDXOverlayTex.Dirty or
(FLastDXW <> W) or (FLastDXRender <> FDXSpotOverlay.RenderVersion) then
+4 -2
View File
@@ -17,9 +17,9 @@
<UseVersionInfo Value="True"/>
<AutoIncrementBuild Value="True"/>
<MinorVersionNr Value="9"/>
<BuildNr Value="294"/>
<BuildNr Value="295"/>
</VersionInfo>
<MacroValues Count="127">
<MacroValues Count="129">
<Macro1 Name="LCLWidgetType" Value="qt6"/>
<Macro2 Name="LCLWidgetType" Value="qt6"/>
<Macro3 Name="LCLWidgetType" Value="qt6"/>
@@ -147,6 +147,8 @@
<Macro125 Name="LCLWidgetType" Value="qt6"/>
<Macro126 Name="LCLWidgetType" Value="qt6"/>
<Macro127 Name="LCLWidgetType" Value="qt6"/>
<Macro128 Name="LCLWidgetType" Value="qt6"/>
<Macro129 Name="LCLWidgetType" Value="qt6"/>
</MacroValues>
<BuildModes>
<Item Name="Debug" Default="True"/>