From 17fa965aec6e359607d60abae8e0d367555125c8 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 14 Aug 2026 18:00:08 +0300 Subject: [PATCH] =?UTF-8?q?feat(dxcluster):=20=D1=81=D0=BF=D0=BE=D1=82?= =?UTF-8?q?=D1=8B=20=D0=BD=D0=B0=20=D0=B2=D1=81=D0=B5=D1=85=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=B4=D0=B0=D0=BF=D1=82=D0=B5=D1=80=D0=B0=D1=85=20?= =?UTF-8?q?+=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20DX=20=D0=B2=20=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B5=20RX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подписи спотов рисовались только на главном пане. Теперь их видит каждый пан, в чьё окно попадает частота спота. Оверлей — ЭКЗЕМПЛЯР НА ПАН, а не один общий: кэш полосы подписей ключуется центром/спаном/шириной, а у панов они свои — общий оверлей пересобирался бы на каждый пан каждый кадр, то есть ровно то, ради чего кэш и заводился. Живёт в 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 --- DXClusterClient.pas | 58 +++++++++-- DXSpotOverlay.pas | 118 +++++++++++++++++----- MainForm.pas | 224 ++++++++++++++++++++++++++++++++--------- PanafallPanel.pas | 28 ++++++ Settings.pas | 4 +- SpectrumView.pas | 3 + SpectrumViewOpengl.pas | 8 +- ewsdr.lpi | 6 +- 8 files changed, 362 insertions(+), 87 deletions(-) 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 @@ + +