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
+93 -25
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; // горизонт затухания, снят один раз на раскладку
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);