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
+175 -12
View File
@@ -33,6 +33,7 @@ uses
WebServer, WebAdapter,
CATAdapter,
SpectrumView, SpectrumViewOpengl,
PanZoomBar,
WidebandView,
StatusBar,
PlatformUtils,
@@ -296,6 +297,12 @@ type
PbRuler: TPaintBox; // полоса частотных меток между спектром и водопадом
PanelSplitter: TPanel; // перетаскиваемый разделитель спектр/водопад
PbWaterfall: TControl;
// ---- Панель пана/зума под водопадом ----
PbPanZoom: TPaintBox; // полоса-ползунок пана зума
FPanZoomBar: TPanZoomBar;
BtnZoomIn: TFlatButton;
BtnZoomDef: TFlatButton;
BtnZoomOut: TFlatButton;
// ---- Status bar ----
StatusPanel: TMainStatusBar;
@@ -437,6 +444,19 @@ type
procedure DoSpectrumClick(PixelX: Integer; PanelWidth: Integer);
procedure DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer);
procedure DoWidebandClick(PixelX: Integer; PanelWidth: Integer);
// ---- Пан/зум ----
procedure PbPanZoomMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PbPanZoomMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
procedure PbPanZoomMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PbPanZoomDblClick(Sender: TObject);
procedure OnPanZoomWindow(AZoom, APan: Double);
procedure BtnZoomInClick(Sender: TObject);
procedure BtnZoomDefClick(Sender: TObject);
procedure BtnZoomOutClick(Sender: TObject);
procedure PositionPanZoomBar(BottomY, RW, H: Integer; AVisible: Boolean);
procedure UpdatePanZoomBar;
procedure BtnVfoSwapClick(Sender: TObject);
procedure BtnVfoACopyBClick(Sender: TObject);
procedure BtnVfoBCopyAClick(Sender: TObject);
@@ -1068,6 +1088,7 @@ begin
FController.FNetwork.Disconnect;
FreeAndNil(FSpecView);
FreeAndNil(FWidebandView);
FreeAndNil(FPanZoomBar);
// Сохраняем настройки при закрытии
if FController.FDevConnected then
@@ -1370,6 +1391,21 @@ begin
// актуальную ширину спектра (UI-зависимая величина).
if FController.FWDSPReady then
FController.FDSPEngine.UpdateAGCLines(FSpectrumWidth);
UpdatePanZoomBar;
end;
rfZoom:
begin
// Зум/пан изменились: окно просмотра, шаг линейки и полоса фильтра
// следуют за span → пересинк + полная перерисовка спектра и водопада.
FSpecView.InvalidateRulerCache;
SyncSpecViewFreq;
UpdatePanZoomBar;
PositionVfoOverlay;
FSpecView.DrawSpectrum;
PbSpectrum.Invalidate;
FSpecView.DrawWaterfall;
if PbWaterfall <> nil then PbWaterfall.Invalidate;
if PbRuler <> nil then PbRuler.Invalidate;
end;
rfFMRpt:
begin
@@ -2194,8 +2230,25 @@ begin
end;
PbWaterfall.Parent := PanelRight;
// ---- Панель пана/зума под водопадом ----
// Кнопки создаём с дефолтными цветами; финальная тема — в ApplyDarkTheme.
FPanZoomBar := TPanZoomBar.Create;
FPanZoomBar.OnZoomPan := OnPanZoomWindow;
PbPanZoom := TPaintBox.Create(Self);
PbPanZoom.Parent := PanelRight;
PbPanZoom.OnPaint := FPanZoomBar.Paint;
PbPanZoom.OnMouseDown := PbPanZoomMouseDown;
PbPanZoom.OnMouseMove := PbPanZoomMouseMove;
PbPanZoom.OnMouseUp := PbPanZoomMouseUp;
PbPanZoom.OnDblClick := PbPanZoomDblClick;
FPanZoomBar.PaintBox := PbPanZoom;
BtnZoomOut := MakeFlatBtn(PanelRight, '', 0, 0, 10, 10, BtnZoomOutClick);
BtnZoomDef := MakeFlatBtn(PanelRight, '⌂', 0, 0, 10, 10, BtnZoomDefClick);
BtnZoomIn := MakeFlatBtn(PanelRight, '+', 0, 0, 10, 10, BtnZoomInClick);
// Initial layout
ResizeSpectrumPanels;
UpdatePanZoomBar;
end;
// ===========================================================================
@@ -2241,7 +2294,9 @@ begin
DeltaY := P.Y - FSplitterDragY0;
TopOff := 0;
AvailH := PanelRight.ClientHeight - TopOff - RULER_H - SPLITTER_H;
// Низ зарезервирован под панель пана/зума (она видна, пока виден водопад).
AvailH := PanelRight.ClientHeight - TopOff - RULER_H - SPLITTER_H
- MulDiv(22, Screen.PixelsPerInch, 96);
if AvailH <= 0 then Exit;
NewSH := FSplitterSH0 + DeltaY;
@@ -2279,7 +2334,9 @@ begin
DeltaY := P.Y - FSplitterDragY0;
TopOff := 0;
AvailH := PanelRight.ClientHeight - TopOff - RULER_H - SPLITTER_H;
// Низ зарезервирован под панель пана/зума (она видна, пока виден водопад).
AvailH := PanelRight.ClientHeight - TopOff - RULER_H - SPLITTER_H
- MulDiv(22, Screen.PixelsPerInch, 96);
if AvailH <= 0 then Exit;
NewSH := FSplitterSH0 + DeltaY;
@@ -2483,12 +2540,19 @@ var
RW, RH, SH, WH, TopOff, WideH, WideSpecH: Integer;
AvailH: Integer;
RULER_H: Integer;
PANZOOM_H: Integer;
ShowPZ: Boolean;
begin
RULER_H := MulDiv(18, Screen.PixelsPerInch, 96);
if PanelRight = nil then Exit;
if Assigned(FSpecView) then SyncSpecViewFreq;
RW := PanelRight.ClientWidth;
RH := PanelRight.ClientHeight;
// Резервируем низ под панель пана/зума (видна, если виден спектр или водопад).
PANZOOM_H := MulDiv(22, Screen.PixelsPerInch, 96);
ShowPZ := FController.FShowSpectrum or FController.FShowWaterfall;
PositionPanZoomBar(RH - PANZOOM_H, RW, PANZOOM_H, ShowPZ);
if ShowPZ then RH := RH - PANZOOM_H;
UpdateWidebandFrequencyView;
WideH := 0;
WideSpecH := 0;
@@ -2712,6 +2776,14 @@ begin
if BtnFMRptPlus <> nil then StyleButton(BtnFMRptPlus, BtnFMRptPlus.Active);
if FCTCSSDropDown <> nil then FCTCSSDropDown.ApplyStyle(StyleButton, T.Panel);
if FStepDropDown <> nil then FStepDropDown.ApplyStyle(StyleButton, T.Panel);
if BtnZoomIn <> nil then StyleButton(BtnZoomIn, False);
if BtnZoomDef <> nil then StyleButton(BtnZoomDef, False);
if BtnZoomOut <> nil then StyleButton(BtnZoomOut, False);
if FPanZoomBar <> nil then
begin
FPanZoomBar.SetTheme(T);
if PbPanZoom <> nil then PbPanZoom.Color := T.SliderBG;
end;
if BtnChannels <> nil then StyleButton(BtnChannels, False);
if FChannelsDropDown <> nil then FChannelsDropDown.ApplyStyle(StyleButton, T.Panel);
StyleButton(BtnNR, BtnNR.Active);
@@ -2995,12 +3067,15 @@ begin
end;
procedure TMainForm.SyncSpecViewFreq;
var ViewCenter, ViewSpan: Double;
begin
// Видимое окно с учётом зума (центр+span). Без зума == FCenterFreq/FSpanHz.
FController.GetViewWindow(ViewCenter, ViewSpan);
FSpecView.VfoA := FController.FVfoA;
FSpecView.VfoB := FController.FVfoB;
FSpecView.ActiveVfo := FController.FActiveVfo;
FSpecView.CenterFreq := FController.FCenterFreq;
FSpecView.SpanHz := FController.FSpanHz;
FSpecView.CenterFreq := ViewCenter;
FSpecView.SpanHz := ViewSpan;
FSpecView.Mode := FController.FMode;
FSpecView.FilterBW := FController.FFilterBW;
if FController.FMode = MODE_FM then
@@ -3017,9 +3092,10 @@ begin
// Бэндплан QO-100 следует за видом спектра (downlink-домен). SetView сам
// отсекает неизменное → пересборки кэша на каждый вызов нет.
if Assigned(FBandPlanOverlay) then
FBandPlanOverlay.SetView(FController.FCenterFreq, FController.FSpanHz);
FBandPlanOverlay.SetView(ViewCenter, ViewSpan);
if UpdateWidebandFrequencyView and FController.FShowWideband and (PbWideband <> nil) then
PbWideband.Invalidate;
UpdatePanZoomBar; // линейка пана следует за частотой/зумом (no-op если без изменений)
end;
@@ -4120,13 +4196,15 @@ procedure TMainForm.DoSpectrumClick(PixelX: Integer; PanelWidth: Integer);
var
ClickFreq: Int64;
StepHz: Int64;
ViewCenter, ViewSpan: Double;
begin
if PanelWidth <= 0 then Exit;
if (FController.FMode = MODE_FM) and FController.FFMStepOn then
StepHz := FM_STEP_HZ[FController.FFMStepIdx]
else
StepHz := 100;
ClickFreq := Round(FController.FCenterFreq + (PixelX / PanelWidth - 0.5) * FController.FSpanHz);
FController.GetViewWindow(ViewCenter, ViewSpan);
ClickFreq := Round(ViewCenter + (PixelX / PanelWidth - 0.5) * ViewSpan);
ClickFreq := ((ClickFreq + StepHz div 2) div StepHz) * StepHz;
if FController.FActiveVfo = 0 then
ApplyVfoA(ClickFreq)
@@ -4226,10 +4304,12 @@ procedure TMainForm.DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer);
var
dPix: Integer;
dFreq: Double;
ViewCenter, ViewSpan: Double;
begin
if PanelWidth <= 0 then Exit;
FController.GetViewWindow(ViewCenter, ViewSpan);
dPix := PixelX - FSpecDragX0;
dFreq := dPix / PanelWidth * FController.FSpanHz;
dFreq := dPix / PanelWidth * ViewSpan;
if FController.FCTun then
// CTUN ON: drag двигает окно просмотра (DDC-центр) без смены VFO; shift,
// сеть и рендер — в контроллере SetCenter / OnControllerState(rfCenterFreq).
@@ -4244,9 +4324,89 @@ begin
end;
end;
// ---------------------------------------------------------------------------
// Панель пана / зума
// ---------------------------------------------------------------------------
procedure TMainForm.PositionPanZoomBar(BottomY, RW, H: Integer; AVisible: Boolean);
var BtnW, Gap, StripW, X: Integer;
begin
if (PbPanZoom = nil) or (BtnZoomIn = nil) then Exit;
PbPanZoom.Visible := AVisible;
BtnZoomIn.Visible := AVisible;
BtnZoomDef.Visible := AVisible;
BtnZoomOut.Visible := AVisible;
if not AVisible then Exit;
Gap := MulDiv(2, Screen.PixelsPerInch, 96);
BtnW := H; // квадратные кнопки в высоту строки
StripW := RW - 3 * BtnW - 4 * Gap;
if StripW < 20 then StripW := 20;
PbPanZoom.SetBounds(0, BottomY, StripW, H);
X := StripW + Gap;
BtnZoomOut.SetBounds(X, BottomY, BtnW, H); Inc(X, BtnW + Gap);
BtnZoomDef.SetBounds(X, BottomY, BtnW, H); Inc(X, BtnW + Gap);
BtnZoomIn.SetBounds(X, BottomY, BtnW, H);
PbPanZoom.Invalidate;
end;
procedure TMainForm.UpdatePanZoomBar;
begin
if FPanZoomBar <> nil then
begin
// Полоса — линейка ПОЛНОГО диапазона (базовые центр/span), стекло-ползунок
// показывает видимое окно (zoom/pan).
FPanZoomBar.SetFreq(FController.FCenterFreq, FController.FSpanHz);
FPanZoomBar.SetState(FController.ZoomFactor, FController.PanSlider);
end;
end;
procedure TMainForm.OnPanZoomWindow(AZoom, APan: Double);
begin
// Окно-селектор задаёт и зум, и пан напрямую (без якоря на VFO).
FController.ApplyZoom(AZoom, APan);
end;
procedure TMainForm.PbPanZoomDblClick(Sender: TObject);
begin
if FPanZoomBar <> nil then FPanZoomBar.HandleDblClick;
end;
procedure TMainForm.PbPanZoomMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
if FPanZoomBar <> nil then FPanZoomBar.HandleMouseDown(Button, X, Y);
end;
procedure TMainForm.PbPanZoomMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
begin
if FPanZoomBar <> nil then FPanZoomBar.HandleMouseMove(X, Y);
end;
procedure TMainForm.PbPanZoomMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
if FPanZoomBar <> nil then FPanZoomBar.HandleMouseUp;
end;
procedure TMainForm.BtnZoomInClick(Sender: TObject);
begin
FController.ZoomIn;
end;
procedure TMainForm.BtnZoomDefClick(Sender: TObject);
begin
FController.ZoomDefault;
end;
procedure TMainForm.BtnZoomOutClick(Sender: TObject);
begin
FController.ZoomOut;
end;
// Спектр
procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var VC, VS: Double;
begin
if Assigned(FVfoOverlay) and
FVfoOverlay.HandleMouseDown(Button, X, Y) then Exit;
@@ -4261,8 +4421,9 @@ begin
if FController.BeaconLockEnabled and (PbSpectrum.Width > 0) and
(FBeaconArming or (ssShift in Shift)) then
begin
FController.GetViewWindow(VC, VS);
FController.BeaconSeedAtHz(
PixelToFreq(X, PbSpectrum.Width, FController.FCenterFreq, FController.FSpanHz));
PixelToFreq(X, PbSpectrum.Width, VC, VS));
FBeaconArming := False;
UpdateBeaconButton;
Exit;
@@ -4851,13 +5012,15 @@ procedure TMainForm.PositionVfoOverlay;
var
VfoX, FilterEndX, OW, NewLeft, NewTop: Integer;
HiHz: Double;
ViewCenter, ViewSpan: Double;
begin
if not Assigned(FVfoOverlay) or not FVfoOverlay.Visible then Exit;
OW := FVfoOverlay.Width;
FController.GetViewWindow(ViewCenter, ViewSpan);
if (PbSpectrum.Width > 0) and (FController.FSpanHz > 0) then
if (PbSpectrum.Width > 0) and (ViewSpan > 0) then
begin
VfoX := Round((FController.FVfoA - FController.FCenterFreq + FController.FSpanHz / 2) / FController.FSpanHz * PbSpectrum.Width)
VfoX := Round((FController.FVfoA - ViewCenter + ViewSpan / 2) / ViewSpan * PbSpectrum.Width)
end
else
VfoX := PbSpectrum.Width div 2;
@@ -4868,8 +5031,8 @@ begin
else
HiHz := FController.FFilterBW / 2;
end;
if (PbSpectrum.Width > 0) and (FController.FSpanHz > 0) then
FilterEndX := VfoX + Round(HiHz / FController.FSpanHz * PbSpectrum.Width)
if (PbSpectrum.Width > 0) and (ViewSpan > 0) then
FilterEndX := VfoX + Round(HiHz / ViewSpan * PbSpectrum.Width)
else
FilterEndX := VfoX;
FilterEndX := Max(0, Min(PbSpectrum.Width - 1, FilterEndX));