diff --git a/DXClusterClient.pas b/DXClusterClient.pas
index 62648f3..b9aac1d 100644
--- a/DXClusterClient.pas
+++ b/DXClusterClient.pas
@@ -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;
diff --git a/DXSpotOverlay.pas b/DXSpotOverlay.pas
index efd34e4..97f0b22 100644
--- a/DXSpotOverlay.pas
+++ b/DXSpotOverlay.pas
@@ -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; // горизонт затухания, снят один раз на раскладку
+ 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.TakeSnapshot;
+// Снимок видимого окна + его подпись. Подпись складывает всё, от чего зависит
+// картинка полосы: позывной, частоту, моду и метку времени (она правит цвет
+// затухания и обновляется при пере-споте). Совпала с прошлой — пересобирать
+// нечего, какой бы ни стала версия стора.
+var
+ i, k: Integer;
+ H: QWord;
+ LoHz, HiHz: Double;
+begin
+ 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;
+ 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), в лесенке дырок не оставляем.
+// спот скрыт (считаем в FHidden), в лесенке дырок не оставляем. Работаем по
+// снимку FArr — его взял TakeSnapshot перед пересборкой.
var
- Arr: TDXSpotArray;
N, i, r, TW, ChipW, ChipX, Row: Integer;
RowRight: array[0..DXSPOT_MAX_ROWS-1] of Integer;
- LoHz, HiHz: Double;
begin
FItemCount := 0;
FHidden := 0;
if (FStore = nil) or (FSpanHz <= 0) or (W <= 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);
+ 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);
diff --git a/MainForm.pas b/MainForm.pas
index 1f73c6c..812739d 100644
--- a/MainForm.pas
+++ b/MainForm.pas
@@ -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
- begin
- FDXSpotOverlay.MaxRows := D.MaxRows;
- FDXSpotOverlay.SetOwnCall(D.Login);
- FDXSpotOverlay.SetFilters(DXModeSetFromMask(D.ModeMask), D.TTLMinutes);
- FDXSpotOverlay.SetEnabled(D.ShowSpots);
- FDXSpotOverlay.Invalidate;
- end;
+ // Настройки общие для всех панов: споты рисуются на каждом, где частота
+ // попадает в его окно.
+ for i := 0 to MAX_PANS - 1 do
+ if FPans[i] <> nil then
+ begin
+ 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
diff --git a/PanafallPanel.pas b/PanafallPanel.pas
index c93be98..794ddf9 100644
--- a/PanafallPanel.pas
+++ b/PanafallPanel.pas
@@ -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;
diff --git a/Settings.pas b/Settings.pas
index 3f4eb06..bee3176 100644
--- a/Settings.pas
+++ b/Settings.pas
@@ -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', '');
diff --git a/SpectrumView.pas b/SpectrumView.pas
index 5646323..2338dca 100644
--- a/SpectrumView.pas
+++ b/SpectrumView.pas
@@ -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);
diff --git a/SpectrumViewOpengl.pas b/SpectrumViewOpengl.pas
index d237e20..16775b5 100644
--- a/SpectrumViewOpengl.pas
+++ b/SpectrumViewOpengl.pas
@@ -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
diff --git a/ewsdr.lpi b/ewsdr.lpi
index 28fef8b..358b700 100644
--- a/ewsdr.lpi
+++ b/ewsdr.lpi
@@ -17,9 +17,9 @@
-
+
-
+
@@ -147,6 +147,8 @@
+
+