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