diff --git a/MainForm.pas b/MainForm.pas index a6bd290..f1727ca 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -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)); diff --git a/PanZoomBar.pas b/PanZoomBar.pas new file mode 100644 index 0000000..5bc62e4 --- /dev/null +++ b/PanZoomBar.pas @@ -0,0 +1,425 @@ +unit PanZoomBar; + +{ + PanZoomBar.pas — нижняя панель пана/зума спектра и водопада. + + Полоса = мини-линейка ПОЛНОГО диапазона (sample rate): цифры частот + деления. + Поверх — окно-селектор «стекло» (полупрозрачное, с объёмной фаской), равное + видимой полосе. Взаимодействие: + • тянешь СЕРЕДИНУ окна → пан (сдвиг видимой полосы); + • тянешь ЛЕВЫЙ/ПРАВЫЙ край → зум (меняешь ширину окна, противоположный край + зафиксирован), курсор у края ⇔; + • клик мимо окна → окно центрируется на курсоре (пан); + • двойной клик → сброс к полному диапазону (зум 0). + Сквозь стекло видны цифры/деления. Callback OnZoomPan(zoom, pan) отдаёт обе + величины (зум 0..1, пан 0..1) — контроллер применяет их напрямую. + + Рисует в чужой TPaintBox (как RulerView) через offscreen-битмап. Полупрозрачность + стекла — реальный per-pixel альфа-бленд (TLazIntfImage). Кнопки +/умолч/- — в MainForm. +} + +{$IFDEF FPC} + {$MODE Delphi} +{$ENDIF} + +interface + +uses + Classes, SysUtils, Graphics, ExtCtrls, Controls, Math, + LCLIntf, LCLType, IntfGraphics, fpImage, AppTheme; + +type + TPanZoomEvent = procedure(AZoom, APan: Double) of object; + + TDragMode = (dmNone, dmBody, dmLeft, dmRight); + + TPanZoomBar = class + private + FPb: TPaintBox; + FBmp: TBitmap; + FTheme: TAppTheme; + FZoom: Double; // 0..1 (0 = без зума, весь span виден) + FPan: Double; // 0..1 (положение окна по span) + FCenterFreq: Double; // центр ПОЛНОГО диапазона (DDC), Гц + FSpanHz: Double; // полный span (= sample rate), Гц + FDragMode: TDragMode; + FDragGrab: Double; // body: (курсор_доля − левый_край_доля) в момент захвата + FDragLo: Double; // зафиксированный левый край окна (для edge-зума), доля + FDragHi: Double; // зафиксированный правый край окна, доля + FOnZoomPan: TPanZoomEvent; + + function VisFraction: Double; // доля span, видимая при текущем зуме + function ThumbGeom(W: Integer; out L, Wd: Integer): Boolean; // px-геометрия окна + procedure CurWindow(out Lo, Hi: Double); // доли краёв окна + procedure EmitWindow(NewLo, NewHi: Double); // окно → zoom/pan + callback + function EdgePx(Wd: Integer): Integer; + procedure DrawRulerInto(W, H: Integer); + procedure BlendThumb(L, Wd, W, H: Integer); + public + constructor Create; + destructor Destroy; override; + + property PaintBox: TPaintBox write FPb; + property OnZoomPan: TPanZoomEvent read FOnZoomPan write FOnZoomPan; + + procedure SetState(AZoom, APan: Double); + procedure SetFreq(ACenterFreq, ASpanHz: Double); + procedure SetTheme(const T: TAppTheme); + procedure Paint(Sender: TObject); + procedure HandleMouseDown(Button: TMouseButton; X, Y: Integer); + procedure HandleMouseMove(X, Y: Integer); + procedure HandleMouseUp; + procedure HandleDblClick; + end; + +implementation + +const + MIN_THUMB_PX = 12; // окно не уже этого на экране — чтобы было за что схватить + MIN_VIS = 0.01; // минимальная видимая доля (= максимальный зум ~100x) + +// Линейная интерполяция байт-канала. +function MixB(Bg, Fg: Integer; A: Double): Integer; inline; +begin + Result := Round(Bg * (1.0 - A) + Fg * A); + if Result < 0 then Result := 0 else if Result > 255 then Result := 255; +end; + +function FreqLabel(Hz: Double): string; +begin + Result := Format('%.3f', [Hz / 1e6]); +end; + +// Видимая доля → коэффициент зума z (инверсия формулы анализатора). +function VisToZoom(Vis: Double): Double; +var V: Double; +begin + V := EnsureRange(Vis, MIN_VIS, 1.0); + Result := EnsureRange((Power(10.0, (1.0 - V) / 0.99) - 1.0) / 9.0, 0.0, 1.0); +end; + +constructor TPanZoomBar.Create; +begin + inherited Create; + FBmp := TBitmap.Create; + FBmp.PixelFormat := pf32bit; + FTheme := DarkTheme; + FZoom := 0.0; + FPan := 0.5; + FCenterFreq := 0.0; + FSpanHz := 192000.0; + FDragMode := dmNone; +end; + +destructor TPanZoomBar.Destroy; +begin + FBmp.Free; + inherited; +end; + +procedure TPanZoomBar.SetTheme(const T: TAppTheme); +begin + FTheme := T; + if Assigned(FPb) then FPb.Invalidate; +end; + +procedure TPanZoomBar.SetState(AZoom, APan: Double); +var NewZoom, NewPan: Double; +begin + NewZoom := EnsureRange(AZoom, 0.0, 1.0); + NewPan := EnsureRange(APan, 0.0, 1.0); + if (Abs(NewZoom - FZoom) < 1e-4) and (Abs(NewPan - FPan) < 1e-4) then Exit; + FZoom := NewZoom; + FPan := NewPan; + if Assigned(FPb) then FPb.Invalidate; +end; + +procedure TPanZoomBar.SetFreq(ACenterFreq, ASpanHz: Double); +begin + if (Abs(ACenterFreq - FCenterFreq) < 0.5) and (Abs(ASpanHz - FSpanHz) < 1.0) then Exit; + FCenterFreq := ACenterFreq; + FSpanHz := ASpanHz; + if Assigned(FPb) then FPb.Invalidate; +end; + +function TPanZoomBar.VisFraction: Double; +// Та же формула, что и span-clip в WDSPEngine: видимая доля = width/bins. +begin + Result := EnsureRange(1.0 - 0.99 * Log10(9.0 * FZoom + 1.0), MIN_VIS, 1.0); +end; + +procedure TPanZoomBar.CurWindow(out Lo, Hi: Double); +var Vis: Double; +begin + Vis := VisFraction; + Lo := EnsureRange(FPan, 0.0, 1.0) * (1.0 - Vis); + Hi := Lo + Vis; +end; + +function TPanZoomBar.EdgePx(Wd: Integer): Integer; +// Зона захвата края — узкая, но не съедает всё узкое окно (оставляем тело). +begin + Result := Max(3, Min(8, Wd div 3)); +end; + +function TPanZoomBar.ThumbGeom(W: Integer; out L, Wd: Integer): Boolean; +var Lo, Hi: Double; +begin + Result := W > 0; + if not Result then begin L := 0; Wd := 0; Exit; end; + CurWindow(Lo, Hi); + Wd := Max(MIN_THUMB_PX, Round((Hi - Lo) * W)); + if Wd > W then Wd := W; + L := Round(Lo * W); + if L < 0 then L := 0; + if L + Wd > W then L := W - Wd; +end; + +procedure TPanZoomBar.EmitWindow(NewLo, NewHi: Double); +var Vis, Z, P: Double; +begin + if NewLo < 0.0 then NewLo := 0.0; + if NewHi > 1.0 then NewHi := 1.0; + if NewHi - NewLo < MIN_VIS then NewHi := NewLo + MIN_VIS; + if NewHi > 1.0 then begin NewHi := 1.0; NewLo := NewHi - MIN_VIS; end; + Vis := NewHi - NewLo; + Z := VisToZoom(Vis); + if (1.0 - Vis) > 1e-6 then P := NewLo / (1.0 - Vis) else P := 0.5; + P := EnsureRange(P, 0.0, 1.0); + // Локально обновляем сразу — драг плавный даже до round-trip контроллера. + FZoom := Z; FPan := P; + if Assigned(FPb) then FPb.Invalidate; + if Assigned(FOnZoomPan) then FOnZoomPan(Z, P); +end; + +procedure TPanZoomBar.DrawRulerInto(W, H: Integer); +// Цифры частот + деления на всю ширину (= полный диапазон). +var + C: TCanvas; + i, X, TickMaj, TickMin, LabelY, NDiv, LabelEvery, TW: Integer; + FreqStart, FreqHz, PixPerDiv: Double; + Lbl: string; +begin + C := FBmp.Canvas; + C.Brush.Style := bsSolid; + C.Brush.Color := FTheme.SliderBG; + C.FillRect(Rect(0, 0, W, H)); + + C.Font.Name := 'Courier New'; + C.Font.Style := []; + C.Font.Height := -Max(7, Round(H * 0.40)); + C.Brush.Style := bsClear; + + TickMaj := Max(3, Round(H * 0.28)); + TickMin := Max(2, Round(H * 0.16)); + LabelY := TickMaj + ((H - TickMaj) - C.TextHeight('0')) div 2; + if LabelY < TickMaj then LabelY := TickMaj; + + NDiv := 8; + if FSpanHz <= 0 then Exit; + FreqStart := FCenterFreq - FSpanHz / 2; + PixPerDiv := W / NDiv; + if PixPerDiv >= (C.TextWidth('000.000') + 8) then LabelEvery := 1 + else if PixPerDiv * 2 >= (C.TextWidth('000.000') + 8) then LabelEvery := 2 + else LabelEvery := 4; + + for i := 0 to NDiv do + begin + X := Round(i * PixPerDiv); + if X >= W then X := W - 1; + C.Pen.Color := FTheme.RulerBorder; + if (i mod LabelEvery) = 0 then + begin + C.MoveTo(X, 0); C.LineTo(X, TickMaj); + FreqHz := FreqStart + i * FSpanHz / NDiv; + Lbl := FreqLabel(FreqHz); + TW := C.TextWidth(Lbl); + C.Font.Color := FTheme.RulerText; + C.TextOut(EnsureRange(X - TW div 2, 0, W - TW), LabelY, Lbl); + end + else + begin + C.MoveTo(X, 0); C.LineTo(X, TickMin); + end; + end; + C.Brush.Style := bsSolid; +end; + +procedure TPanZoomBar.BlendThumb(L, Wd, W, H: Integer); +// Полупрозрачное «стекло» с объёмной фаской поверх линейки. +var + Intf: TLazIntfImage; + x, y, top, bot: Integer; + c: TFPColor; + tR, tG, tB, bgR, bgG, bgB, nR, nG, nB: Integer; + A, RowT, Gloss: Double; + Active: Boolean; +begin + if (Wd <= 0) or (W <= 0) or (H <= 2) then Exit; + Active := FDragMode <> dmNone; + if Active then + begin + tR := GetRValue(ColorToRGB(FTheme.SliderThumbDrag)); + tG := GetGValue(ColorToRGB(FTheme.SliderThumbDrag)); + tB := GetBValue(ColorToRGB(FTheme.SliderThumbDrag)); + end + else + begin + tR := GetRValue(ColorToRGB(FTheme.SliderThumbNorm)); + tG := GetGValue(ColorToRGB(FTheme.SliderThumbNorm)); + tB := GetBValue(ColorToRGB(FTheme.SliderThumbNorm)); + end; + + top := 1; + bot := H - 2; + Intf := FBmp.CreateIntfImage; + try + for y := top to bot do + begin + if (bot - top) > 0 then RowT := (y - top) / (bot - top) else RowT := 0.0; + Gloss := 1.18 - 0.42 * RowT; // объём: ярче вверху, темнее внизу + A := 0.42 + 0.10 * (1.0 - RowT); + for x := L to L + Wd - 1 do + begin + if (x < 0) or (x >= W) then Continue; + c := Intf.Colors[x, y]; + bgR := c.red shr 8; bgG := c.green shr 8; bgB := c.blue shr 8; + nR := MixB(bgR, EnsureRange(Round(tR * Gloss), 0, 255), A); + nG := MixB(bgG, EnsureRange(Round(tG * Gloss), 0, 255), A); + nB := MixB(bgB, EnsureRange(Round(tB * Gloss), 0, 255), A); + c.red := nR * 257; c.green := nG * 257; c.blue := nB * 257; c.alpha := $FFFF; + Intf.Colors[x, y] := c; + end; + end; + FBmp.LoadFromIntfImage(Intf); + finally + Intf.Free; + end; + + // Объёмная фаска + маркеры краёв (две вертикальные риски — «ручки» ресайза). + with FBmp.Canvas do + begin + Pen.Width := 1; Brush.Style := bsClear; + Pen.Color := FTheme.SliderThumbBdr; + Rectangle(L, top, L + Wd, bot + 1); + Pen.Color := RGBToColor(EnsureRange(tR + 70, 0, 255), + EnsureRange(tG + 70, 0, 255), + EnsureRange(tB + 70, 0, 255)); + MoveTo(L + 1, top + 1); LineTo(L + Wd - 1, top + 1); + MoveTo(L + 1, top + 1); LineTo(L + 1, bot); + Pen.Color := RGBToColor(EnsureRange(tR - 60, 0, 255), + EnsureRange(tG - 60, 0, 255), + EnsureRange(tB - 60, 0, 255)); + MoveTo(L + 1, bot); LineTo(L + Wd - 1, bot); + MoveTo(L + Wd - 1, top + 1); LineTo(L + Wd - 1, bot + 1); + // «ручки» захвата по краям (видны, когда окно достаточно широкое) + if Wd >= 3 * MIN_THUMB_PX then + begin + Pen.Color := FTheme.SliderThumbBdr; + MoveTo(L + 3, top + 2); LineTo(L + 3, bot - 1); + MoveTo(L + Wd - 4, top + 2); LineTo(L + Wd - 4, bot - 1); + end; + Brush.Style := bsSolid; + end; +end; + +procedure TPanZoomBar.Paint(Sender: TObject); +var W, H, L, Wd: Integer; +begin + if FPb = nil then Exit; + W := FPb.Width; H := FPb.Height; + if (W <= 0) or (H <= 0) then Exit; + if (FBmp.Width <> W) or (FBmp.Height <> H) then FBmp.SetSize(W, H); + + DrawRulerInto(W, H); + if ThumbGeom(W, L, Wd) then BlendThumb(L, Wd, W, H); + + FBmp.Canvas.Brush.Style := bsClear; + FBmp.Canvas.Pen.Color := FTheme.RulerBorder; + FBmp.Canvas.Rectangle(0, 0, W, H); + FBmp.Canvas.Brush.Style := bsSolid; + + FPb.Canvas.Draw(0, 0, FBmp); +end; + +procedure TPanZoomBar.HandleMouseDown(Button: TMouseButton; X, Y: Integer); +var W, L, Wd, Edge: Integer; Lo, Hi, MouseFrac: Double; +begin + if (FPb = nil) or (Button <> mbLeft) then Exit; + W := FPb.Width; + if not ThumbGeom(W, L, Wd) then Exit; + CurWindow(Lo, Hi); + FDragLo := Lo; FDragHi := Hi; + Edge := EdgePx(Wd); + MouseFrac := X / W; + + if (X >= L) and (X < L + Edge) then + FDragMode := dmLeft + else if (X > L + Wd - Edge) and (X <= L + Wd) then + FDragMode := dmRight + else if (X >= L) and (X <= L + Wd) then + begin + FDragMode := dmBody; + FDragGrab := MouseFrac - Lo; // тянем тело за точку захвата + end + else + begin + // Клик мимо окна → центрируем окно на курсоре, дальше тянем телом. + FDragMode := dmBody; + FDragGrab := (Hi - Lo) / 2.0; + EmitWindow(MouseFrac - FDragGrab, MouseFrac - FDragGrab + (Hi - Lo)); + end; + if Assigned(FPb) then FPb.Invalidate; +end; + +procedure TPanZoomBar.HandleMouseMove(X, Y: Integer); +var W, L, Wd, Edge: Integer; MouseFrac, Width: Double; +begin + if FPb = nil then Exit; + W := FPb.Width; + if W <= 0 then Exit; + MouseFrac := X / W; + + if FDragMode = dmNone then + begin + // Hover: курсор-подсказка над краями (зум) / телом (пан). + if ThumbGeom(W, L, Wd) then + begin + Edge := EdgePx(Wd); + if ((X >= L) and (X < L + Edge)) or ((X > L + Wd - Edge) and (X <= L + Wd)) then + FPb.Cursor := crSizeWE + else if (X >= L) and (X <= L + Wd) then + FPb.Cursor := crSizeAll + else + FPb.Cursor := crDefault; + end; + Exit; + end; + + case FDragMode of + dmLeft: EmitWindow(Min(MouseFrac, FDragHi - MIN_VIS), FDragHi); + dmRight: EmitWindow(FDragLo, Max(MouseFrac, FDragLo + MIN_VIS)); + dmBody: + begin + Width := FDragHi - FDragLo; // ширина фиксирована → зум не меняется + EmitWindow(EnsureRange(MouseFrac - FDragGrab, 0.0, 1.0 - Width), + EnsureRange(MouseFrac - FDragGrab, 0.0, 1.0 - Width) + Width); + end; + end; +end; + +procedure TPanZoomBar.HandleMouseUp; +begin + if FDragMode = dmNone then Exit; + FDragMode := dmNone; + if Assigned(FPb) then FPb.Invalidate; +end; + +procedure TPanZoomBar.HandleDblClick; +begin + // Сброс к полному диапазону. + FDragMode := dmNone; + EmitWindow(0.0, 1.0); +end; + +end. diff --git a/RadioController.pas b/RadioController.pas index 5729ef5..06e3816 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -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 в diff --git a/Settings.pas b/Settings.pas index 4d2c209..7cd894c 100644 --- a/Settings.pas +++ b/Settings.pas @@ -67,6 +67,8 @@ type FMStepIdx: Integer; // 0..3 (6.25/12.5/20/25 kHz) FMRptDir: Integer; // RPT_NONE/RPT_MINUS/RPT_PLUS FMRptOffsetHz: Double; // Hz, 0 = use band default + ZoomFactor: Double; // зум спектра/водопада 0.0..1.0 (0.0 = без зума) + PanSlider: Double; // положение окна зума 0..1 (0.5 = центр) end; // TX-настройки per-device. Хранятся в JSON-секции "tx" под MAC, @@ -407,6 +409,8 @@ begin B.FMStepIdx := 3; // 25 kHz B.FMRptDir := 0; B.FMRptOffsetHz := 0.0; + B.ZoomFactor := 0.0; + B.PanSlider := 0.5; end; class procedure TSettingsManager.DefaultGlobal(out G: TGlobalSettings); @@ -823,6 +827,7 @@ begin JW(O,'ctcss_on',B.CTCSSOn); JW(O,'ctcss_idx',B.CTCSSToneIdx); JW(O,'fmstep_on',B.FMStepOn); JW(O,'fmstep_idx',B.FMStepIdx); JW(O,'fmrpt_dir',B.FMRptDir); JW(O,'fmrpt_offset',B.FMRptOffsetHz); + JW(O,'zoom_factor',B.ZoomFactor); JW(O,'pan_slider',B.PanSlider); end; function TSettingsManager.LoadBand(const MAC: array of Byte; BandIdx: Integer; @@ -852,6 +857,8 @@ begin B.FMStepIdx := EnsureRange(JI(O,'fmstep_idx', B.FMStepIdx), 0, 3); B.FMRptDir := EnsureRange(JI(O,'fmrpt_dir', B.FMRptDir), 0, 2); B.FMRptOffsetHz := JD(O,'fmrpt_offset', B.FMRptOffsetHz); + B.ZoomFactor := EnsureRange(JD(O,'zoom_factor', B.ZoomFactor), 0.0, 1.0); + B.PanSlider := EnsureRange(JD(O,'pan_slider', B.PanSlider), 0.0, 1.0); end; procedure TSettingsManager.SaveWindowBounds(L, T, W, H, DPI: Integer; Maximized: Boolean); diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 1a0b188..2cb3170 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -204,6 +204,16 @@ type FWfAvgMode: Integer; FWfAvgTimeMS: Double; + // Zoom/pan анализатора (span-clip в SetAnalyzer). Конвенция WDSP/Thetis: + // FZoomFactor 0.0..1.0, где 0.0 = весь span (без зума), 1.0 = максимум. + // FPanSlider 0..1 (положение окна по span). FViewLowHz/FViewHighHz — + // границы видимого окна в Гц относительно DDC-центра (low отрицательный), + // пересчитываются в ApplyRXAnalyzerSettings. + FZoomFactor: Double; + FPanSlider: Double; + FViewLowHz: Double; + FViewHighHz: Double; + FOnAudio: TOnAudioReady; FOnSpectrum: TOnSpectrumReady; FOnWaterfall: TOnWaterfallReady; @@ -431,6 +441,14 @@ type // S-meter function GetSMeterDBm: Double; + // Zoom/pan: задать коэффициент зума и положение пана и переармировать + // RX-анализатор. View* — границы видимого окна (Гц отн. DDC-центра). + procedure SetZoomPan(AZoom, APan: Double); + property ZoomFactor: Double read FZoomFactor; + property PanSlider: Double read FPanSlider; + property ViewLowHz: Double read FViewLowHz; + property ViewHighHz: Double read FViewHighHz; + property Initialized: Boolean read FInitialized; property SampleRate: Integer read FSampleRate; property TXSampleRate: Integer read FTXSampleRate; @@ -768,6 +786,10 @@ begin FWfDetector := 0; // Peak FWfAvgMode := 3; // Log Recursive FWfAvgTimeMS := 120.0; + FZoomFactor := 0.0; // 0.0 = весь span, без зума + FPanSlider := 0.5; // окно по центру + FViewLowHz := -FSampleRate / 2.0; + FViewHighHz := FSampleRate / 2.0; SetLength(FRXIn, FBufSize * 2); // in_size пар @ FSampleRate (4096*2) SetLength(FRXOut, FAudioBufSize * 2); // out_size пар @ FAudioRate (1024*2) @@ -906,7 +928,11 @@ procedure TWDSPEngine.ApplyRXAnalyzerSettings; var AnalyzerFFT, AvgCount, WfAvgCount, MaxW, Overlap, DisplayPixels: Integer; Backmult, WfBackmult: Double; -const KEEP_TIME_SEC = 0.10; + Bins, SpanClipL, SpanClipH, ZWidth: Integer; + BinWidth, BW, ZoomSlider: Double; +const + KEEP_TIME_SEC = 0.10; + ZOOM_LIMIT = 100.0; // максимальная кратность зума begin AnalyzerFFT := CalcDisplayFFTSize; FDispPos := 0; @@ -919,8 +945,25 @@ begin AverageTimeToParams(FSpecAvgMode, FSpecAvgTimeMS, AvgCount, Backmult); AverageTimeToParams(FWfAvgMode, FWfAvgTimeMS, WfAvgCount, WfBackmult); + // Zoom/pan: считаем span-clip для SetAnalyzer (fscLin/fscHin). + // clp=0 — оставляем полный span FFT, чтобы при зуме 0.0 видимая полоса была + // ровно == FSampleRate (остальной код это предполагает). Зум делает только + // span-clip: FZoomFactor=0.0 → ZWidth=Bins → SpanClipL=SpanClipH=0. + BinWidth := FSampleRate / AnalyzerFFT; + Bins := AnalyzerFFT; + BW := Bins * BinWidth; + ZoomSlider := Log10(9.0 * EnsureRange(FZoomFactor, 0.0, 1.0) + 1.0); + ZWidth := Round(Bins * (1.0 - (1.0 - 1.0 / ZOOM_LIMIT) * ZoomSlider)); + ZWidth := EnsureRange(ZWidth, 1, Bins); + SpanClipL := Floor(EnsureRange(FPanSlider, 0.0, 1.0) * (Bins - ZWidth)); + SpanClipH := Bins - ZWidth - SpanClipL; + // Видимое окно в Гц относительно DDC-центра (low отрицательный). Поправка + // BinWidth/2 — у комплексного FFT на одну отрицательную ячейку больше. + FViewLowHz := -(0.5 * BW - SpanClipL * BinWidth + BinWidth / 2.0); + FViewHighHz := (0.5 * BW - SpanClipH * BinWidth - BinWidth / 2.0); + SetAnalyzer(RX_DISP_ID, 2, 1, 1, @FFlp[0], AnalyzerFFT, DISPLAY_BLOCK_SIZE, - FWindowType, 14.0, Overlap, 0, 0.0, 0.0, DisplayPixels, + FWindowType, 14.0, Overlap, 0, SpanClipL, SpanClipH, DisplayPixels, 1, 0, 0.0, 0.0, MaxW); SetDisplayDetectorMode(RX_DISP_ID, 0, FSpecDetector); SetDisplayAverageMode (RX_DISP_ID, 0, FSpecAvgMode); @@ -978,6 +1021,16 @@ begin ApplyTXAnalyzerSettings; end; +procedure TWDSPEngine.SetZoomPan(AZoom, APan: Double); +// Задаёт зум/пан и переармирует RX-анализатор (SetAnalyzer внутри WDSP +// защищён собственной критической секцией, дисплей-поток не мешает). +begin + FZoomFactor := EnsureRange(AZoom, 0.0, 1.0); + FPanSlider := EnsureRange(APan, 0.0, 1.0); + if FAnalyzerOpen then + ApplyRXAnalyzerSettings; +end; + procedure TWDSPEngine.CloseAnalyzer; begin if not FAnalyzerOpen then Exit; diff --git a/WebAdapter.pas b/WebAdapter.pas index 0d70f46..6e32175 100644 --- a/WebAdapter.pas +++ b/WebAdapter.pas @@ -369,13 +369,14 @@ procedure TWebAdapter.OnBeaconSeed(Frac: Double); begin FSyncFreq := Frac; FController.Invoke(@SyncBeaconSeed); end; procedure TWebAdapter.SyncBeaconSeed; -var Hz: Double; +var Hz, VC, VS: Double; begin - // Frac (доля 0..1 по ширине спектра) → абс. display-Hz от ЖИВЫХ центра/спана - // контроллера (зеркалит десктоп PixelToFreq(X,W,FCenterFreq,FSpanHz)). Так - // наведение точно, даже когда LO ретюнится локом и клиентский center отстаёт. + // Frac (доля 0..1 по ширине спектра) → абс. display-Hz от ЖИВОГО видимого окна + // контроллера (зеркалит десктоп PixelToFreq с учётом зума). Так наведение + // точно, даже когда LO ретюнится локом и клиентский center отстаёт. if (FSyncFreq < 0) or (FSyncFreq > 1) then Exit; - Hz := FController.FCenterFreq + (FSyncFreq - 0.5) * FController.FSpanHz; + FController.GetViewWindow(VC, VS); + Hz := VC + (FSyncFreq - 0.5) * VS; FController.BeaconSeedAtHz(Hz); end; @@ -466,7 +467,11 @@ procedure TWebAdapter.PushState; var StatusText, BoardText, IPText, SupplyText: string; PLLText, RXText, TXText, SeqText: string; + ViewCenter, ViewSpan: Double; begin + // Видимое окно (центр+span) с учётом зума: пиксели спектра уже зумлены + // анализатором, web-метки/маппинг должны соответствовать. + FController.GetViewWindow(ViewCenter, ViewSpan); if FController.FNetwork.Connected then begin if FController.FRunning then StatusText := 'Running' @@ -558,13 +563,13 @@ begin FController.FWaterfallBuf, FController.FLastSMeter, FController.FVfoA, FController.FMode, FController.FFilterBW, FController.FAGCMode, FController.FAGCTop, - FController.FSpanHz, FController.FVolume, + ViewSpan, FController.FVolume, FController.FWfAGCEnabled, FController.FWfNFEnabled, FController.FCurrentBand, FController.FRunning and FController.FNetwork.Connected, FController.FRunning, FController.FMuted, FController.FCTun, FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, - FController.FCenterFreq, FController.FFilter, + ViewCenter, FController.FFilter, FController.FVfoB, FController.FActiveVfo, FController.FTransmitting, FController.FDrivePercent, FController.FAtten, FController.FTuning, FController.FDisplayDuplex,