feat(spectrum): zoom + pan для спектра и водопада (WDSP span-clip)

Зум через нативный span-clip WDSP (fscLin/fscHin в SetAnalyzer): clp=0,
видимое окно сужается клипом, при зуме 0 полоса == sample rate (старое
поведение сохранено). Конвенция как в Thetis: ZoomFactor 0=без зума..1=макс.

- WDSPEngine: SetZoomPan + расчёт span-clip, ViewLowHz/ViewHighHz.
- RadioController: GetViewWindow (единая истина видимого окна для оверлеев и
  мыши), ApplyZoom, ZoomIn/Out по лестнице кратностей, ZoomDefault. Якорь
  зума кнопками — на маркере активного VFO. rfZoom.
- Per-band персист зума/пана (TBandSettings + Save/Load/Restore).
- MainForm: маршрутизация оверлеев/кликов через GetViewWindow (покрывает CPU
  и OpenGL), резерв строки PANZOOM_H в layout + сплиттере, rfZoom-рендер.
- WebAdapter: web-спектр/наведение на view-окно (делит те же WDSP-пиксели).
- PanZoomBar (новый): нижняя полоса = линейка полного диапазона (частоты +
  деления), поверх окно-селектор «стекло» (полупрозрачное, объёмная фаска).
  Середина=пан, края=зум, клик мимо=центрировать, двойной клик=сброс.

Сборка ewsdr (--ws=qt6) и ewsdrd — ОК. На железе не проверено.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:18:24 +03:00
co-authored by Claude Opus 4.8
parent 0e550dd9f9
commit 371b2858bd
6 changed files with 814 additions and 22 deletions
+140 -1
View File
@@ -66,7 +66,7 @@ type
rfRxGain, // Pluto: hw-gain (auto/manual dB)
rfVolume, rfMute, rfDrive, rfAtten,
rfBand, rfXvtr, rfBandRestore, // rfBandRestore: явная смена диапазона (сброс Wf-avg в UI)
rfCenterFreq, rfSpan, rfSampleRate,
rfCenterFreq, rfSpan, rfSampleRate, rfZoom,
rfCTun, rfDuplex, rfRxMuteOnTx, // rfRxMuteOnTx: QO-100 self-monitor toggle
rfRunning, rfConnected, rfTransmitting, rfTuning,
rfNR, rfNB, rfSNB, rfANF,
@@ -138,6 +138,7 @@ type
procedure ApplyModeDefaults; // дефолтный фильтр/девиация под текущий FMode
procedure SanitizeFilterForMode; // FM: индекс фильтра 0/1 + BW/девиация (state)
function ActiveVfoHz: Double; // частота активного VFO (A или B)
function AnchoredPan(AZoom: Double): Double; // пан, центрирующий окно на маркере VFO
procedure ApplyTuneCore(VfoHz: Double); // CTUN shift/center/scroll для актив. VFO
public
// ============================================================
@@ -181,6 +182,8 @@ type
FCenterFreq: Double;
FSpanHz: Double;
FSampleRate: Integer;
FZoomFactor: Double; // зум спектра/водопада 0.05..1.0 (1.0 = без зума)
FPanSlider: Double; // положение окна зума 0..1 (0.5 = центр)
FCurrentBand: Integer;
FCurrentXvtr: Integer;
@@ -480,6 +483,18 @@ type
procedure SetSpan(Hz: Integer);
procedure SetSampleRate(Hz: Integer);
// ---- Zoom/pan спектра и водопада ----
// GetViewWindow — единая истина «видимого окна» (центр+span в Гц) для всех
// оверлеев и маппинга мыши: при зуме отдаёт окно из WDSP-анализатора, иначе
// базовые FCenterFreq/FSpanHz.
procedure GetViewWindow(out ACenter: Double; out ASpan: Double);
procedure ApplyZoom(AZoom, APan: Double); // задать зум+пан, сохранить в band, Changed(rfZoom)
procedure ZoomIn; // шаг увеличения (якорь — маркер активного VFO)
procedure ZoomOut; // шаг уменьшения
procedure ZoomDefault; // сброс в 1.0 (весь span)
function ZoomFactor: Double;
function PanSlider: Double;
// QO-100 beacon lock — стабилизация дрейфа LNB по опорному маяку.
// QO-100 beacon lock (= декодер + трим LOError; декодер = эталон частоты).
procedure SetBeaconLock(On_: Boolean); // вкл/выкл лок (запускает декодер)
@@ -610,6 +625,7 @@ begin
FRxMuteOnTx := True; // QO-100 self-monitor выкл по умолчанию (RX заглушён на TX)
FVolume := 70; FDrivePercent := 50; FDriveLevel := 0; FAtten := 0;
FCenterFreq := FVfoA; FSpanHz := 192000; FSampleRate := 192000;
FZoomFactor := 0.0; FPanSlider := 0.5; // 0.0 = весь span (без зума)
FCurrentBand := 5; FCurrentXvtr := -1;
FSpectrumBufCount := 1024; FWaterfallBufCount := 1024;
FPAMaxPower := 100.0;
@@ -1452,6 +1468,8 @@ begin
Result.CTun := FCTun;
Result.CenterHz := FCenterFreq; // DDC-центр → восстановим offset CTun при загрузке
Result.SpanHz := FSpanHz;
Result.ZoomFactor := FZoomFactor;
Result.PanSlider := FPanSlider;
Result.FMSQOn := FFMSQOn;
Result.FMSQLevel := FFMSQLevel;
Result.CTCSSOn := FFMCTCSSOn;
@@ -1667,6 +1685,12 @@ begin
ApplyBandDSP(B);
// --- Zoom/pan диапазона: восстанавливаем и переармируем анализатор ---
FZoomFactor := EnsureRange(B.ZoomFactor, 0.0, 1.0);
FPanSlider := EnsureRange(B.PanSlider, 0.0, 1.0);
if Assigned(FDSPEngine) then FDSPEngine.SetZoomPan(FZoomFactor, FPanSlider);
Changed(rfZoom);
// --- VFO / drive (только RestoreBand — bandstack-путь VFO не трогает) ---
// При CTun восстанавливаем сохранённый DDC-центр, чтобы VFO остался на своём
// месте в полосе (а не прыгал в центр). Берём только если offset влезает в
@@ -2327,6 +2351,121 @@ end;
procedure TRadioController.SetSpan(Hz: Integer);
begin FSpanHz := Hz; Changed(rfSpan); { TODO wiring } end;
// ════════════════════════════════════════════════════════════════════════════
// Zoom / pan спектра и водопада
// ════════════════════════════════════════════════════════════════════════════
function TRadioController.ZoomFactor: Double;
begin Result := FZoomFactor; end;
function TRadioController.PanSlider: Double;
begin Result := FPanSlider; end;
procedure TRadioController.GetViewWindow(out ACenter: Double; out ASpan: Double);
// Видимое окно (центр+span, Гц) с учётом зума. Формула совпадает с расчётом
// span-clip в WDSPEngine.ApplyRXAnalyzerSettings (bw=FSpanHz, clp=0), поэтому
// оверлеи точно ложатся на пиксели. Без зума → базовые центр/span.
var
FVis, OneMinusFVis, VisSpan, LowEdgeOff, CenterOff: Double;
begin
if (FZoomFactor <= 0.0) or (FSpanHz <= 0.0) then
begin
ACenter := FCenterFreq;
ASpan := FSpanHz;
Exit;
end;
OneMinusFVis := 0.99 * Log10(9.0 * EnsureRange(FZoomFactor, 0.0, 1.0) + 1.0);
FVis := 1.0 - OneMinusFVis; // доля span после зума
if FVis < 1.0 / 100.0 then FVis := 1.0 / 100.0; // ZOOM_LIMIT
VisSpan := FVis * FSpanHz;
LowEdgeOff := -FSpanHz / 2.0 + EnsureRange(FPanSlider, 0.0, 1.0) * OneMinusFVis * FSpanHz;
CenterOff := LowEdgeOff + VisSpan / 2.0;
ACenter := FCenterFreq + CenterOff;
ASpan := VisSpan;
end;
function TRadioController.AnchoredPan(AZoom: Double): Double;
// Положение пана, при котором видимое окно центрировано на маркере активного
// VFO. Клампится в [0..1], чтобы окно не вылезало за span (у краёв маркер
// перестаёт быть строго по центру — окно упирается в край).
var OneMinusFVis, U: Double;
begin
if (AZoom <= 0.0) or (FSpanHz <= 0.0) then Exit(0.5);
OneMinusFVis := 0.99 * Log10(9.0 * EnsureRange(AZoom, 0.0, 1.0) + 1.0);
if OneMinusFVis <= 1e-9 then Exit(0.5);
U := (ActiveVfoHz - FCenterFreq) / FSpanHz; // нормализованный сдвиг маркера
Result := EnsureRange(0.5 + U / OneMinusFVis, 0.0, 1.0);
end;
procedure TRadioController.ApplyZoom(AZoom, APan: Double);
begin
FZoomFactor := EnsureRange(AZoom, 0.0, 1.0);
FPanSlider := EnsureRange(APan, 0.0, 1.0);
if Assigned(FDSPEngine) then
FDSPEngine.SetZoomPan(FZoomFactor, FPanSlider);
if (FCurrentBand >= 0) and (FCurrentBand < CFG_BAND_COUNT) then
begin
FBandCache[FCurrentBand].ZoomFactor := FZoomFactor;
FBandCache[FCurrentBand].PanSlider := FPanSlider;
end;
Changed(rfZoom);
end;
// Лестница кратностей увеличения: мелкий шаг в начале (1.0→1.2→1.45…), крупнее
// к концу. Кнопки [+]/[-] шагают по ней; ZoomFactor (0..1) хранит соответствующий
// z. Перевод кратность↔z — инверсия формулы анализатора (см. ApplyRXAnalyzerSettings).
const
ZOOM_MAG_LADDER: array[0..16] of Double =
(1.0, 1.1, 1.15, 1.2, 1.45, 1.75, 2.1, 2.5, 3.0, 3.7, 4.7, 6.2, 8.5, 12.0, 20.0, 40.0, 100.0);
function ZoomMagToZ(M: Double): Double;
begin
if M <= 1.0 then Exit(0.0);
// M = 1/(1-0.99*log10(9z+1)) ⇒ z = (10^((1-1/M)/0.99) - 1)/9
Result := EnsureRange((Power(10.0, (1.0 - 1.0 / M) / 0.99) - 1.0) / 9.0, 0.0, 1.0);
end;
function ZoomZToMag(Z: Double): Double;
begin
Result := 1.0 / (1.0 - 0.99 * Log10(9.0 * EnsureRange(Z, 0.0, 1.0) + 1.0));
end;
procedure TRadioController.ZoomIn;
var CurMag, Z: Double; i: Integer;
begin
CurMag := ZoomZToMag(FZoomFactor);
for i := 0 to High(ZOOM_MAG_LADDER) do
if ZOOM_MAG_LADDER[i] > CurMag * 1.001 then
begin
Z := ZoomMagToZ(ZOOM_MAG_LADDER[i]);
ApplyZoom(Z, AnchoredPan(Z));
Exit;
end;
end;
procedure TRadioController.ZoomOut;
var CurMag, Z: Double; i: Integer;
begin
CurMag := ZoomZToMag(FZoomFactor);
for i := High(ZOOM_MAG_LADDER) downto 0 do
if ZOOM_MAG_LADDER[i] < CurMag / 1.001 then
begin
if ZOOM_MAG_LADDER[i] <= 1.0 then ApplyZoom(0.0, 0.5)
else
begin
Z := ZoomMagToZ(ZOOM_MAG_LADDER[i]);
ApplyZoom(Z, AnchoredPan(Z));
end;
Exit;
end;
ApplyZoom(0.0, 0.5);
end;
procedure TRadioController.ZoomDefault;
begin
ApplyZoom(0.0, 0.5);
end;
procedure TRadioController.SetSampleRate(Hz: Integer);
// Смена частоты дискретизации DDC. В этом приложении span ≡ sample rate.
// Несёт: пересоздание WDSP-канала (Close+FlushQueue+Open), новый rate в