mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 19:45:09 +00:00
Лупа сделала прицеливание возможным, но целиться всё равно надо руками. AUTO находит маяк сам, в текущем окне лупы: что видно, то и обыскивается — заодно зумом можно сузить область поиска. Критерий — не «самый громкий», иначе поймается первая же SSB-станция. Копим min-hold по кадрам лупы (~1.6 с): у маяка несущая непрерывна и минимум остаётся высоким, а речь, телеграф и цифра в паузах проваливаются к шуму и срезаются минимумом. Это тот самый «непрерывный след», который видно на водопаде, только числом. По накопленному: шумовой пол = медиана, кандидат = максимум скользящего среднего шириной с полосу приёма (±450 Гц) — среднее по полосе, а не отдельный бин, потому что маяк это плато ~900 Гц, и по такой мере он выигрывает у узкой пораженки с той же высотой пика. Порог 6 дБ над полом; центр уточняется центроидом превышения, чтобы наведение село на середину плато. - Тумблер рядом с FOLLOW, состояние в настройках (beacon_zoom_auto). - На локе автопоиск молчит: в контур не лезем. - После наведения выдержка ~3 с — дать декодеру попробовать захватиться. - Нашли там же, где уже стоит наведение (ближе 150 Гц) — не трогаем. BeaconSeedAtHz дёргает Reseed, и без этой проверки при слабом сигнале автопоиск сбрасывал бы захват по кругу, мешая декодеру сойтись. - В накопление идут только свежие кадры: таймер формы (25 Гц) быстрее анализатора (15 к/с), и повторный учёт кадра сокращал бы окно наблюдения, ради которого min-hold и заведён. В строке захвата появляется пометка AUTO — и только пока автопоиск реально работает: на локе она гаснет, чтобы не обещать действие, которого нет. Сборка ewsdr (--ws=qt6) и ewsdrd — ОК. На эфире не проверено. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1710 lines
66 KiB
ObjectPascal
1710 lines
66 KiB
ObjectPascal
unit BeaconScopeForm;
|
||
|
||
{ TBeaconScopeForm — окно QO-100 beacon-декодера.
|
||
|
||
Три части сверху вниз:
|
||
1. ЛУПА НАВЕДЕНИЯ (BEACON TUNING) — узкий кусок спектра вокруг маяка в
|
||
высоком разрешении + мини-водопад. На общей спектрограмме (полоса
|
||
захвата 576к и шире) центральный маяк занимает пару пикселей, и ткнуть
|
||
в него мышью почти нельзя; здесь то же наведение делается по широкому
|
||
следу. Клик = TRadioController.BeaconSeedAtHz, ровно как по главному
|
||
спектру — способ навестись со спектрограммы никуда не девался.
|
||
Увеличение делает САМ WDSP: у лупы свой analyzer со своим FFT и своим
|
||
span-clip (см. TWDSPEngine.SetBeaconZoom), поэтому окно детальное, а не
|
||
растянутые бины главного дисплея.
|
||
2. Констелляция BPSK + метрики захвата (carrier/symbol lock, SNR, скорость,
|
||
снос NCO) — смотровое окно демодулятора.
|
||
3. Декодированный бюллетень (кадры AO-40).
|
||
|
||
Открывается из RX-блока MainForm (ПКМ по BEACON). Данные тянутся из
|
||
TRadioController по таймеру (~25 Гц). }
|
||
|
||
{$mode objfpc}{$H+}
|
||
|
||
interface
|
||
|
||
uses
|
||
Classes, SysUtils, Math, StrUtils, Types,
|
||
Forms, Controls, Graphics, ExtCtrls, StdCtrls,
|
||
AppTheme, BeaconDecoder, BeaconFEC, RadioController, DpiUtils, FlatMemo,
|
||
FlatButton, WaterfallView;
|
||
|
||
const
|
||
// Потолок точек в кадре лупы (движок отдаёт BCN_ZOOM_PIXELS = 1024).
|
||
BCN_MAX_PIX = 4096;
|
||
// Границы ширины окна лупы. Ниже 300 Гц смысла нет — маяк BPSK 400 Bd
|
||
// занимает ~800 Гц; выше 200 кГц лупа перестаёт быть лупой.
|
||
BCN_SPAN_MIN = 600.0;
|
||
BCN_SPAN_MAX = 200000.0;
|
||
BCN_SPAN_DEF = 40000.0;
|
||
BCN_ZOOM_STEP = 1.6;
|
||
// Полоса приёма декодера (± от несущей) — та же цифра, что у маркера на
|
||
// главном спектре в MainForm.UpdateBeaconButton.
|
||
BCN_DEC_BW_HZ = 450.0;
|
||
// Цвета маркеров — ровно те же, что на главном спектре
|
||
// (SpectrumView.DrawBeaconMarkersRaw / SpectrumViewOpengl.DrawBeaconMarkersGL),
|
||
// чтобы лупа и панорама читались как одна картинка.
|
||
BCN_CLR_REF = TColor($0000FF00); // clLime — где маяк ДОЛЖЕН быть (пунктир)
|
||
BCN_CLR_TRK = TColor($000AA5FF); // отслеживаемый центроид (оранж)
|
||
BCN_CLR_DEC = TColor($0020D0FF); // грани полосы приёма + центр наведения
|
||
// Автопоиск маяка. Копим min-hold по кадрам лупы: у маяка несущая непрерывна,
|
||
// и минимум за пару секунд остаётся высоким, а любая SSB/CW-станция в паузах
|
||
// проваливается к шуму — это и отсеивает «громко, но не маяк».
|
||
BCN_AUTO_FRAMES = 24; // кадров в min-hold (~1.6 с при 15 к/с анализатора)
|
||
BCN_AUTO_COOLDOWN = 75; // тиков паузы после наведения (~3 с при 25 Гц)
|
||
BCN_AUTO_MIN_DB = 6.0; // насколько кандидат должен возвышаться над полом
|
||
BCN_AUTO_MIN_MOVE = 150.0; // ближе этого к текущему наведению не пере-наводим
|
||
|
||
type
|
||
TBeaconScopeForm = class(TForm)
|
||
private
|
||
FCtl: TRadioController;
|
||
FHeader: TPanel;
|
||
FTitle: TLabel;
|
||
FSubtitle: TLabel;
|
||
FBox: TPaintBox;
|
||
FBulletin: TPanel;
|
||
FBulletinTitle: TLabel;
|
||
FBulletinHint: TLabel;
|
||
FMemo: TFlatMemo; // декодированный текст бюллетеня (AO-40 кадры)
|
||
FTimer: TTimer;
|
||
FTheme: TAppTheme;
|
||
FLastFrames: Int64; // счётчик кадров на прошлом тике (детект нового)
|
||
// Констелляция+метрики: кадр в офскрине, статичная обвязка — в отдельном
|
||
// кэше (идиома TSpectrumView.FGridBitmap).
|
||
FScope: TBitmap;
|
||
FScopeChrome: TBitmap;
|
||
FChromeW, FChromeH: Integer; // размер, под который собрана обвязка
|
||
FScopeValid: Boolean; // в FScope лежит актуальный кадр
|
||
|
||
// --- Лупа наведения ---
|
||
FTune: TPanel; // карточка BEACON TUNING
|
||
FTuneTitle: TLabel;
|
||
FTuneHint: TLabel;
|
||
FSpecBox: TPaintBox; // спектр лупы + линейка частот
|
||
FWfBox: TPaintBox; // мини-водопад лупы
|
||
FWf: TWaterfallView;
|
||
FStrip: TBitmap; // офскрин спектра (без него мигает)
|
||
FBtnZoomIn, FBtnZoomOut, FBtnReset, FBtnFollow, FBtnAuto: TFlatButton;
|
||
|
||
FCenterHz: Double; // центр окна лупы (display Hz), 0 = ещё не задан
|
||
FSpanHz: Double; // ширина окна лупы
|
||
FFollow: Boolean; // держать наведение декодера в окне
|
||
FViewLo, FViewHi: Double; // фактическое окно последнего кадра (display Hz)
|
||
FPix: array[0..BCN_MAX_PIX - 1] of Single;
|
||
FPixCount: Integer;
|
||
FWfPix: array[0..BCN_MAX_PIX - 1] of Single;
|
||
FHaveFrame: Boolean;
|
||
FSpecLo, FSpecHi: Double; // сглаженные границы шкалы дБ
|
||
// Рендер как во всей программе: кадр собирается в офскрин ТОЛЬКО когда есть
|
||
// что перерисовывать (FZoomDirty), а OnPaint лишь блитит битмап и кладёт
|
||
// сверху курсор. Иначе expose-события и движения мыши гоняли бы полную
|
||
// сборку кадра.
|
||
FZoomDirty: Boolean;
|
||
FStripValid: Boolean; // в FStrip лежит кадр под текущий размер
|
||
FLastRefHz, FLastTrkHz, FLastDecHz: Double; // детект сдвига маркеров
|
||
// Состояние захвата, снятое в PollZoom: рисовать его надо в кадре лупы, а
|
||
// лезть за копией TBeaconScope на каждую отрисовку незачем.
|
||
FState: TBeaconState;
|
||
FSNRdB: Double;
|
||
|
||
// --- Автопоиск ---
|
||
FAuto: Boolean; // кнопка AUTO взведена
|
||
FAutoMin: array[0..BCN_MAX_PIX - 1] of Single; // min-hold кадров
|
||
FAutoFrames: Integer; // сколько кадров уже в min-hold
|
||
FAutoCnt: Integer; // точек в кадре, под который копится min-hold
|
||
FAutoLo, FAutoHi: Double; // окно, под которое копится (сменилось — сброс)
|
||
FAutoCooldown: Integer; // тиков паузы после наведения
|
||
FCursorX: Integer; // -1 = курсор вне лупы
|
||
FDragging: Boolean; // ЛКМ зажата в лупе
|
||
FDragMoved: Boolean; // увели мышь → это панорамирование, не клик
|
||
FDragX: Integer;
|
||
FDragCenter: Double;
|
||
|
||
procedure DoTick(Sender: TObject);
|
||
procedure ScopeLayout(W, H: Integer; out GraphR, MetricsR, PlotR: TRect;
|
||
out cx, cy, r: Integer; out Wide: Boolean);
|
||
function MetricTileRect(const MetricsR: TRect; Wide: Boolean;
|
||
n: Integer): TRect;
|
||
procedure BuildScopeChrome(W, H: Integer); // статичная обвязка в кэш
|
||
procedure DrawScope; // кадр констелляции в офскрин
|
||
procedure DoPaint(Sender: TObject);
|
||
procedure FormResize(Sender: TObject);
|
||
procedure PollFrame;
|
||
function FrameToText(const Frame: TBeaconFrame): string;
|
||
|
||
// --- Лупа ---
|
||
procedure BuildTunePanel;
|
||
procedure LayoutTunePanel;
|
||
procedure PollZoom; // забрать кадр лупы у контроллера
|
||
procedure ClampWindow; // окно не вылезает из полосы захвата
|
||
procedure RestoreZoomState; // окно лупы из настроек
|
||
procedure StoreZoomState; // окно лупы в настройки
|
||
function DefaultCenterHz: Double; // куда смотреть, пока не выбрали
|
||
function XToFreq(X, W: Integer): Double;
|
||
function FreqToX(F: Double; W: Integer): Integer;
|
||
procedure ZoomBy(Factor: Double; AnchorX, W: Integer);
|
||
procedure DrawZoomStrip; // сборка кадра лупы в офскрин
|
||
procedure PaintSpec(Sender: TObject);
|
||
procedure PaintWf(Sender: TObject);
|
||
procedure SpecMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure SpecMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
|
||
procedure SpecMouseUp(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure SpecMouseLeave(Sender: TObject);
|
||
procedure SpecWheel(Sender: TObject; Shift: TShiftState;
|
||
WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
|
||
procedure SpecDblClick(Sender: TObject);
|
||
procedure BtnZoomInClick(Sender: TObject);
|
||
procedure BtnZoomOutClick(Sender: TObject);
|
||
procedure BtnResetClick(Sender: TObject);
|
||
procedure BtnFollowClick(Sender: TObject);
|
||
procedure BtnAutoClick(Sender: TObject);
|
||
procedure AutoReset; // сбросить накопление min-hold
|
||
procedure AutoService(NewFrame: Boolean); // одна итерация автопоиска
|
||
function AutoFindBeacon(out FreqHz: Double): Boolean;
|
||
procedure StyleTuneButtons;
|
||
protected
|
||
procedure DoShow; override;
|
||
procedure DoHide; override;
|
||
public
|
||
constructor CreateWith(AOwner: TComponent; ACtl: TRadioController);
|
||
destructor Destroy; override;
|
||
procedure ApplyTheme(const T: TAppTheme);
|
||
end;
|
||
|
||
implementation
|
||
|
||
constructor TBeaconScopeForm.CreateWith(AOwner: TComponent; ACtl: TRadioController);
|
||
var
|
||
WorkArea: TRect;
|
||
MaxW, MaxH: Integer;
|
||
begin
|
||
inherited CreateNew(AOwner);
|
||
FCtl := ACtl;
|
||
FTheme := DarkTheme;
|
||
|
||
// Геометрия формы задаётся ниже уже в DPI-aware координатах.
|
||
Scaled := False;
|
||
Caption := 'QO-100 Beacon';
|
||
BorderStyle := bsSizeable;
|
||
if AOwner is TCustomForm then
|
||
begin
|
||
WorkArea := TCustomForm(AOwner).BoundsRect;
|
||
WorkArea := Screen.MonitorFromRect(WorkArea).WorkareaRect;
|
||
Position := poOwnerFormCenter;
|
||
end
|
||
else
|
||
begin
|
||
WorkArea := Screen.PrimaryMonitor.WorkareaRect;
|
||
Position := poScreenCenter;
|
||
end;
|
||
MaxW := WorkArea.Right - WorkArea.Left - DpiScale(48);
|
||
MaxH := WorkArea.Bottom - WorkArea.Top - DpiScale(48);
|
||
Width := Min(DpiScale(820), MaxW);
|
||
Height := Min(DpiScale(940), MaxH);
|
||
Constraints.MinWidth := Min(DpiScale(560), Width);
|
||
Constraints.MinHeight := Min(DpiScale(700), Height);
|
||
Color := FTheme.BG;
|
||
|
||
FCenterHz := 0.0; // задастся первым тиком (DefaultCenterHz)
|
||
FSpanHz := BCN_SPAN_DEF;
|
||
FFollow := True;
|
||
FCursorX := -1;
|
||
FSpecLo := -130.0;
|
||
FSpecHi := -60.0;
|
||
|
||
FHeader := TPanel.Create(Self);
|
||
FHeader.Parent := Self;
|
||
FHeader.Align := alTop;
|
||
FHeader.Height := DpiScale(72);
|
||
FHeader.BevelOuter := bvNone;
|
||
FHeader.Color := FTheme.Panel;
|
||
|
||
FTitle := TLabel.Create(Self);
|
||
FTitle.Parent := FHeader;
|
||
FTitle.Caption := 'QO-100 BEACON DECODER';
|
||
FTitle.AutoSize := False;
|
||
FTitle.SetBounds(DpiScale(20), DpiScale(12),
|
||
DpiScale(520), DpiScale(26));
|
||
FTitle.Font.Size := 15;
|
||
FTitle.Font.Style := [fsBold];
|
||
FTitle.Font.Color := FTheme.Text;
|
||
|
||
FSubtitle := TLabel.Create(Self);
|
||
FSubtitle.Parent := FHeader;
|
||
FSubtitle.Caption :=
|
||
'Zoomed tuning window, BPSK constellation and AO-40 telemetry';
|
||
FSubtitle.AutoSize := False;
|
||
FSubtitle.SetBounds(DpiScale(20), DpiScale(40),
|
||
DpiScale(620), DpiScale(20));
|
||
FSubtitle.Font.Size := 9;
|
||
FSubtitle.Font.Color := FTheme.TextDim;
|
||
|
||
BuildTunePanel;
|
||
|
||
FBulletin := TPanel.Create(Self);
|
||
FBulletin.Parent := Self;
|
||
FBulletin.Align := alBottom;
|
||
FBulletin.Height := DpiScale(236);
|
||
FBulletin.BevelOuter := bvNone;
|
||
FBulletin.Color := FTheme.Panel;
|
||
|
||
FBulletinTitle := TLabel.Create(Self);
|
||
FBulletinTitle.Parent := FBulletin;
|
||
FBulletinTitle.Caption := 'DECODED BULLETIN';
|
||
FBulletinTitle.AutoSize := False;
|
||
FBulletinTitle.SetBounds(DpiScale(16), DpiScale(9),
|
||
DpiScale(220), DpiScale(20));
|
||
FBulletinTitle.Font.Size := 9;
|
||
FBulletinTitle.Font.Style := [fsBold];
|
||
FBulletinTitle.Font.Color := FTheme.MeterOn;
|
||
|
||
FBulletinHint := TLabel.Create(Self);
|
||
FBulletinHint.Parent := FBulletin;
|
||
FBulletinHint.Caption := 'latest AO-40 frames · read-only';
|
||
FBulletinHint.AutoSize := False;
|
||
FBulletinHint.SetBounds(DpiScale(250), DpiScale(9),
|
||
DpiScale(300), DpiScale(20));
|
||
FBulletinHint.Anchors := [akTop, akRight];
|
||
FBulletinHint.Left := FBulletin.ClientWidth - DpiScale(316);
|
||
FBulletinHint.Alignment := taRightJustify;
|
||
FBulletinHint.Font.Size := 8;
|
||
FBulletinHint.Font.Color := FTheme.TextDim;
|
||
|
||
// Бюллетень остаётся обычным read-only memo: текст можно выделить и скопировать.
|
||
FMemo := TFlatMemo.Create(Self);
|
||
FMemo.Parent := FBulletin;
|
||
FMemo.Align := alClient;
|
||
FMemo.BorderSpacing.Left := DpiScale(12);
|
||
FMemo.BorderSpacing.Top := DpiScale(36);
|
||
FMemo.BorderSpacing.Right := DpiScale(12);
|
||
FMemo.BorderSpacing.Bottom := DpiScale(12);
|
||
FMemo.ReadOnly := True;
|
||
FMemo.WordWrap := False;
|
||
FMemo.Font.Size := 9;
|
||
FMemo.SetAppTheme(FTheme);
|
||
|
||
FBox := TPaintBox.Create(Self);
|
||
FBox.Parent := Self;
|
||
FBox.Align := alClient;
|
||
FBox.OnPaint := @DoPaint;
|
||
FScope := TBitmap.Create;
|
||
FScope.PixelFormat := pf32bit;
|
||
FScopeChrome := TBitmap.Create;
|
||
FScopeChrome.PixelFormat := pf32bit;
|
||
FChromeW := 0;
|
||
FChromeH := 0;
|
||
OnResize := @FormResize;
|
||
FormResize(nil);
|
||
|
||
FTimer := TTimer.Create(Self);
|
||
FTimer.Interval := 40; // ~25 Гц
|
||
FTimer.OnTimer := @DoTick;
|
||
FTimer.Enabled := True;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Лупа наведения: построение
|
||
// ===========================================================================
|
||
|
||
procedure TBeaconScopeForm.BuildTunePanel;
|
||
begin
|
||
FTune := TPanel.Create(Self);
|
||
FTune.Parent := Self;
|
||
FTune.Top := FHeader.Height + 1; // ставим ПОД шапкой до Align (LCL сортирует по Top)
|
||
FTune.Align := alTop;
|
||
FTune.Height := DpiScale(252);
|
||
FTune.BevelOuter := bvNone;
|
||
FTune.Color := FTheme.BG;
|
||
|
||
FTuneTitle := TLabel.Create(Self);
|
||
FTuneTitle.Parent := FTune;
|
||
FTuneTitle.Caption := 'BEACON TUNING';
|
||
FTuneTitle.AutoSize := False;
|
||
FTuneTitle.SetBounds(DpiScale(20), DpiScale(10), DpiScale(300), DpiScale(18));
|
||
FTuneTitle.Font.Size := 8;
|
||
FTuneTitle.Font.Style := [fsBold];
|
||
FTuneTitle.Font.Color := FTheme.MeterOn;
|
||
|
||
FTuneHint := TLabel.Create(Self);
|
||
FTuneHint.Parent := FTune;
|
||
FTuneHint.Caption :=
|
||
'click the beacon to point the decoder · AUTO finds it in this window';
|
||
FTuneHint.AutoSize := False;
|
||
FTuneHint.SetBounds(DpiScale(120), DpiScale(11), DpiScale(360), DpiScale(16));
|
||
FTuneHint.Font.Size := 8;
|
||
FTuneHint.Font.Color := FTheme.TextDim;
|
||
|
||
FBtnFollow := MakeFlatBtn(FTune, 'FOLLOW', 0, 0,
|
||
DpiScale(66), DpiScale(22), @BtnFollowClick);
|
||
FBtnAuto := MakeFlatBtn(FTune, 'AUTO', 0, 0,
|
||
DpiScale(52), DpiScale(22), @BtnAutoClick);
|
||
FBtnReset := MakeFlatBtn(FTune, 'RESET', 0, 0,
|
||
DpiScale(58), DpiScale(22), @BtnResetClick);
|
||
FBtnZoomOut := MakeFlatBtn(FTune, '−', 0, 0,
|
||
DpiScale(30), DpiScale(22), @BtnZoomOutClick);
|
||
FBtnZoomIn := MakeFlatBtn(FTune, '+', 0, 0,
|
||
DpiScale(30), DpiScale(22), @BtnZoomInClick);
|
||
StyleTuneButtons;
|
||
|
||
FSpecBox := TPaintBox.Create(Self);
|
||
FSpecBox.Parent := FTune;
|
||
FSpecBox.OnPaint := @PaintSpec;
|
||
FSpecBox.OnMouseDown := @SpecMouseDown;
|
||
FSpecBox.OnMouseMove := @SpecMouseMove;
|
||
FSpecBox.OnMouseUp := @SpecMouseUp;
|
||
FSpecBox.OnMouseLeave := @SpecMouseLeave;
|
||
FSpecBox.OnMouseWheel := @SpecWheel;
|
||
FSpecBox.OnDblClick := @SpecDblClick;
|
||
FSpecBox.Cursor := crCross;
|
||
|
||
FWfBox := TPaintBox.Create(Self);
|
||
FWfBox.Parent := FTune;
|
||
FWfBox.OnPaint := @PaintWf;
|
||
FWfBox.OnMouseDown := @SpecMouseDown;
|
||
FWfBox.OnMouseMove := @SpecMouseMove;
|
||
FWfBox.OnMouseUp := @SpecMouseUp;
|
||
FWfBox.OnMouseLeave := @SpecMouseLeave;
|
||
FWfBox.OnMouseWheel := @SpecWheel;
|
||
FWfBox.OnDblClick := @SpecDblClick;
|
||
FWfBox.Cursor := crCross;
|
||
|
||
// Водопад лупы — тот же движок, что у главного окна: своя палитра, свой
|
||
// гистограммный AGC. Кадры идут по одному (интервал 1): у лупы окно узкое,
|
||
// накапливать max-hold нечего.
|
||
FWf := TWaterfallView.Create;
|
||
FWf.PbWaterfall := FWfBox;
|
||
FWf.WfFrameInterval := 1;
|
||
FWf.WfAGCEnabled := True;
|
||
FWf.SetTheme(FTheme);
|
||
|
||
FStrip := TBitmap.Create;
|
||
FStrip.PixelFormat := pf32bit;
|
||
|
||
LayoutTunePanel;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.StyleTuneButtons;
|
||
|
||
procedure Style(B: TFlatButton);
|
||
begin
|
||
if B = nil then Exit;
|
||
B.ClrNorm := FTheme.BtnNorm;
|
||
B.ClrActive := FTheme.BtnActive;
|
||
B.ClrHot := FTheme.BtnHot;
|
||
B.ClrBorder := FTheme.BtnBorderNorm;
|
||
B.ClrText := FTheme.BtnText;
|
||
B.ClrTextAct := FTheme.BtnTextActive;
|
||
B.Font.Size := 8;
|
||
B.Invalidate;
|
||
end;
|
||
|
||
begin
|
||
Style(FBtnZoomIn);
|
||
Style(FBtnZoomOut);
|
||
Style(FBtnReset);
|
||
Style(FBtnFollow);
|
||
Style(FBtnAuto);
|
||
if FBtnFollow <> nil then FBtnFollow.Active := FFollow;
|
||
if FBtnAuto <> nil then FBtnAuto.Active := FAuto;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.LayoutTunePanel;
|
||
// Ряд кнопок справа в строке заголовка, под ним спектр, под ним водопад.
|
||
var
|
||
W, X, Y, SpecTop, SpecH, WfTop, WfH, Pad: Integer;
|
||
begin
|
||
if FTune = nil then Exit;
|
||
W := FTune.ClientWidth;
|
||
Pad := DpiScale(14);
|
||
|
||
Y := DpiScale(8);
|
||
X := W - Pad;
|
||
Dec(X, FBtnFollow.Width); FBtnFollow.SetBounds(X, Y, FBtnFollow.Width, FBtnFollow.Height);
|
||
Dec(X, DpiScale(6) + FBtnAuto.Width);
|
||
FBtnAuto.SetBounds(X, Y, FBtnAuto.Width, FBtnAuto.Height);
|
||
Dec(X, DpiScale(6) + FBtnReset.Width);
|
||
FBtnReset.SetBounds(X, Y, FBtnReset.Width, FBtnReset.Height);
|
||
Dec(X, DpiScale(10) + FBtnZoomIn.Width);
|
||
FBtnZoomIn.SetBounds(X, Y, FBtnZoomIn.Width, FBtnZoomIn.Height);
|
||
Dec(X, DpiScale(4) + FBtnZoomOut.Width);
|
||
FBtnZoomOut.SetBounds(X, Y, FBtnZoomOut.Width, FBtnZoomOut.Height);
|
||
|
||
FTuneTitle.SetBounds(DpiScale(20), DpiScale(10), DpiScale(96), DpiScale(18));
|
||
FTuneHint.SetBounds(DpiScale(122), DpiScale(11),
|
||
Max(0, X - DpiScale(132)), DpiScale(16));
|
||
|
||
SpecTop := DpiScale(36);
|
||
WfH := DpiScale(64);
|
||
WfTop := FTune.ClientHeight - DpiScale(10) - WfH;
|
||
SpecH := Max(DpiScale(60), WfTop - SpecTop - DpiScale(2));
|
||
FSpecBox.SetBounds(Pad, SpecTop, Max(DpiScale(40), W - 2 * Pad), SpecH);
|
||
FWfBox.SetBounds(Pad, WfTop, Max(DpiScale(40), W - 2 * Pad), WfH);
|
||
if FWf <> nil then
|
||
FWf.SetWaterfallBitmapSize(FWfBox.Width, FWfBox.Height);
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.ApplyTheme(const T: TAppTheme);
|
||
begin
|
||
FTheme := T;
|
||
Color := T.BG;
|
||
if FHeader <> nil then FHeader.Color := T.Panel;
|
||
if FTitle <> nil then FTitle.Font.Color := T.Text;
|
||
if FSubtitle <> nil then FSubtitle.Font.Color := T.TextDim;
|
||
if FBulletin <> nil then FBulletin.Color := T.Panel;
|
||
if FBulletinTitle <> nil then FBulletinTitle.Font.Color := T.MeterOn;
|
||
if FBulletinHint <> nil then FBulletinHint.Font.Color := T.TextDim;
|
||
if FMemo <> nil then
|
||
FMemo.SetAppTheme(T);
|
||
if FTune <> nil then FTune.Color := T.BG;
|
||
if FTuneTitle <> nil then FTuneTitle.Font.Color := T.MeterOn;
|
||
if FTuneHint <> nil then FTuneHint.Font.Color := T.TextDim;
|
||
StyleTuneButtons;
|
||
if FWf <> nil then FWf.SetTheme(T);
|
||
FStripValid := False;
|
||
FChromeW := 0; // тема поменялась — обвязку пересобрать
|
||
FScopeValid := False;
|
||
if FSpecBox <> nil then FSpecBox.Invalidate;
|
||
if FWfBox <> nil then FWfBox.Invalidate;
|
||
if FBox <> nil then FBox.Invalidate;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.FormResize(Sender: TObject);
|
||
var
|
||
BulletinH, MaxBulletinH, TuneH: Integer;
|
||
begin
|
||
TuneH := 0;
|
||
if FTune <> nil then TuneH := FTune.Height;
|
||
if (FHeader <> nil) and (FBulletin <> nil) then
|
||
begin
|
||
MaxBulletinH := ClientHeight - FHeader.Height - TuneH - DpiScale(270);
|
||
BulletinH := Min(DpiScale(236),
|
||
Max(DpiScale(150), MaxBulletinH));
|
||
if FBulletin.Height <> BulletinH then
|
||
FBulletin.Height := BulletinH;
|
||
end;
|
||
LayoutTunePanel;
|
||
if FTitle <> nil then
|
||
FTitle.Width := Max(0, FHeader.ClientWidth - DpiScale(40));
|
||
if FSubtitle <> nil then
|
||
FSubtitle.Width := Max(0, FHeader.ClientWidth - DpiScale(40));
|
||
if FBulletinHint <> nil then
|
||
begin
|
||
FBulletinHint.Left := DpiScale(250);
|
||
FBulletinHint.Width := Max(0,
|
||
FBulletin.ClientWidth - DpiScale(266));
|
||
end;
|
||
FStripValid := False;
|
||
FChromeW := 0; // размер поменялся — обвязку пересобрать
|
||
FScopeValid := False;
|
||
if FBox <> nil then FBox.Invalidate;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.DoTick(Sender: TObject);
|
||
begin
|
||
if not Visible then Exit;
|
||
PollFrame;
|
||
PollZoom;
|
||
// Констелляция и метрики обновляются каждый тик по существу — но сборка
|
||
// кадра идёт в офскрин из DrawScope, а Paint только копирует.
|
||
FScopeValid := False;
|
||
if FBox <> nil then FBox.Invalidate;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Лупа наведения: данные и геометрия окна
|
||
// ===========================================================================
|
||
|
||
function TBeaconScopeForm.DefaultCenterHz: Double;
|
||
// Куда смотреть, пока пользователь не выбрал сам: на наведение декодера, если
|
||
// оно есть, иначе на измеренную частоту маяка, иначе на опорную (10489.750).
|
||
begin
|
||
Result := 0;
|
||
if FCtl = nil then Exit;
|
||
Result := FCtl.BeaconDecodeFreqHz;
|
||
if Result > 0 then Exit;
|
||
Result := FCtl.BeaconTrackedFreqHz;
|
||
if Result > 0 then Exit;
|
||
Result := FCtl.BeaconRefHz;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.ClampWindow;
|
||
// Окно лупы живёт внутри полосы захвата: за её краем анализатору нечего резать.
|
||
var
|
||
CapLo, CapHi, Half: Double;
|
||
begin
|
||
if FCtl = nil then Exit;
|
||
FCtl.GetCaptureWindow(CapLo, CapHi);
|
||
if CapHi - CapLo <= 0 then Exit;
|
||
FSpanHz := EnsureRange(FSpanHz, BCN_SPAN_MIN, Min(BCN_SPAN_MAX, CapHi - CapLo));
|
||
Half := FSpanHz / 2.0;
|
||
FCenterHz := EnsureRange(FCenterHz, CapLo + Half, CapHi - Half);
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Автопоиск маяка
|
||
// ===========================================================================
|
||
// Ищем в ТЕКУЩЕМ окне лупы — что видно, то и обыскивается. Это и предсказуемо,
|
||
// и даёт управление: зумом пользователь сам сужает область поиска.
|
||
|
||
procedure TBeaconScopeForm.AutoReset;
|
||
begin
|
||
FAutoFrames := 0;
|
||
FAutoCnt := 0;
|
||
end;
|
||
|
||
function TBeaconScopeForm.AutoFindBeacon(out FreqHz: Double): Boolean;
|
||
// По накопленному min-hold: шумовой пол = медиана, кандидат = максимум
|
||
// скользящего среднего шириной с полосу приёма декодера. Именно среднее по
|
||
// полосе, а не отдельный бин: маяк — плато ~900 Гц, и по нему он выигрывает у
|
||
// узкой пораженки/бирди с той же высотой пика.
|
||
var
|
||
N, i, Half, i1, i2, Best: Integer;
|
||
BinHz, FloorDb, Sum, BestVal, V, WSum, FSum, Wt: Double;
|
||
Sorted: array of Double;
|
||
TmpD: Double;
|
||
a, b: Integer;
|
||
begin
|
||
Result := False;
|
||
FreqHz := 0;
|
||
N := FAutoCnt;
|
||
if (N < 16) or (FAutoFrames < BCN_AUTO_FRAMES) then Exit;
|
||
BinHz := (FAutoHi - FAutoLo) / (N - 1);
|
||
if BinHz <= 0 then Exit;
|
||
|
||
// Шумовой пол — медиана по прореженной выборке (как в шкале дБ кадра).
|
||
SetLength(Sorted, (N + 7) div 8);
|
||
for i := 0 to High(Sorted) do
|
||
Sorted[i] := FAutoMin[Min(i * 8, N - 1)];
|
||
for a := 1 to High(Sorted) do
|
||
begin
|
||
TmpD := Sorted[a];
|
||
b := a - 1;
|
||
while (b >= 0) and (Sorted[b] > TmpD) do
|
||
begin
|
||
Sorted[b + 1] := Sorted[b];
|
||
Dec(b);
|
||
end;
|
||
Sorted[b + 1] := TmpD;
|
||
end;
|
||
FloorDb := Sorted[Length(Sorted) div 2];
|
||
|
||
Half := Max(1, Round(BCN_DEC_BW_HZ / BinHz));
|
||
if 2 * Half + 1 >= N then Exit; // окно уже полосы приёма — не ищем
|
||
|
||
// Скользящее среднее одним проходом: сумма правится краями окна.
|
||
Sum := 0;
|
||
for i := 0 to 2 * Half do Sum := Sum + FAutoMin[i];
|
||
Best := Half;
|
||
BestVal := Sum / (2 * Half + 1);
|
||
for i := Half + 1 to N - Half - 1 do
|
||
begin
|
||
Sum := Sum - FAutoMin[i - Half - 1] + FAutoMin[i + Half];
|
||
V := Sum / (2 * Half + 1);
|
||
if V > BestVal then
|
||
begin
|
||
BestVal := V;
|
||
Best := i;
|
||
end;
|
||
end;
|
||
if BestVal - FloorDb < BCN_AUTO_MIN_DB then Exit; // ничего похожего на маяк
|
||
|
||
// Уточнение центра: центроид превышения над полом внутри полосы. Декодер
|
||
// дотянет и с ошибкой в сотню герц (у него свой acquisition), но так наведение
|
||
// сразу садится на середину плато.
|
||
i1 := Max(0, Best - Half);
|
||
i2 := Min(N - 1, Best + Half);
|
||
WSum := 0;
|
||
FSum := 0;
|
||
for i := i1 to i2 do
|
||
begin
|
||
Wt := FAutoMin[i] - FloorDb;
|
||
if Wt <= 0 then Continue;
|
||
WSum := WSum + Wt;
|
||
FSum := FSum + Wt * i;
|
||
end;
|
||
if WSum <= 0 then Exit;
|
||
FreqHz := FAutoLo + (FSum / WSum) * BinHz;
|
||
Result := True;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.AutoService(NewFrame: Boolean);
|
||
// Раз в тик, при взведённой AUTO. Пока декодер солидно залочен — не мешаем
|
||
// контуру; потерял лок (или его и не было) — копим min-hold и наводимся сами.
|
||
// В накопление идут только СВЕЖИЕ кадры: таймер формы быстрее анализатора, и
|
||
// повторный учёт одного и того же кадра сокращал бы окно наблюдения, ради
|
||
// которого min-hold и заведён.
|
||
var
|
||
i: Integer;
|
||
F: Double;
|
||
begin
|
||
if not FAuto then Exit;
|
||
if FCtl = nil then Exit;
|
||
|
||
if FState = bsLock then
|
||
begin
|
||
AutoReset; // лок держится — искать нечего
|
||
Exit;
|
||
end;
|
||
if FAutoCooldown > 0 then
|
||
begin
|
||
Dec(FAutoCooldown); // ждём, пока декодер попробует захватиться
|
||
AutoReset;
|
||
Exit;
|
||
end;
|
||
if (not NewFrame) or (FPixCount < 16) then Exit;
|
||
|
||
// Окно переехало (зум/пан/смена rate) — накопленное больше не про эти бины.
|
||
if (FPixCount <> FAutoCnt) or (Abs(FViewLo - FAutoLo) > 0.5)
|
||
or (Abs(FViewHi - FAutoHi) > 0.5) then
|
||
begin
|
||
FAutoCnt := FPixCount;
|
||
FAutoLo := FViewLo;
|
||
FAutoHi := FViewHi;
|
||
FAutoFrames := 0;
|
||
end;
|
||
|
||
if FAutoFrames = 0 then
|
||
for i := 0 to FAutoCnt - 1 do FAutoMin[i] := FPix[i]
|
||
else
|
||
for i := 0 to FAutoCnt - 1 do
|
||
if FPix[i] < FAutoMin[i] then FAutoMin[i] := FPix[i];
|
||
Inc(FAutoFrames);
|
||
if FAutoFrames < BCN_AUTO_FRAMES then Exit;
|
||
|
||
// Наводимся, только если нашли ДРУГОЕ место. Если декодер и так смотрит сюда,
|
||
// а лока нет (слабый сигнал) — пусть работает: BeaconSeedAtHz дёргает Reseed,
|
||
// и повторные наведения на ту же частоту сбрасывали бы захват по кругу.
|
||
if AutoFindBeacon(F) then
|
||
if (FCtl.BeaconDecodeFreqHz <= 0)
|
||
or (Abs(F - FCtl.BeaconDecodeFreqHz) > BCN_AUTO_MIN_MOVE) then
|
||
begin
|
||
FCtl.BeaconSeedAtHz(F); // тот же путь, что клик мышью
|
||
FAutoCooldown := BCN_AUTO_COOLDOWN;
|
||
end;
|
||
AutoReset; // не нашли (или не двигались) — копим заново
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.RestoreZoomState;
|
||
// Окно лупы переживает перезапуск: ширина, положение и FOLLOW лежат в
|
||
// глобальных настройках (персист — общий SaveGlobalSettings при выходе).
|
||
// Положение хранится сдвигом от опорной частоты маяка, поэтому восстановление
|
||
// не зависит от накопленной калибровки LOError.
|
||
begin
|
||
if FCtl = nil then Exit;
|
||
FSpanHz := EnsureRange(FCtl.FBcnZoomSpanHz, BCN_SPAN_MIN, BCN_SPAN_MAX);
|
||
FFollow := FCtl.FBcnZoomFollow;
|
||
FAuto := FCtl.FBcnZoomAuto;
|
||
if FCtl.BeaconRefHz > 0 then
|
||
FCenterHz := FCtl.BeaconRefHz + FCtl.FBcnZoomOffsetHz
|
||
else
|
||
FCenterHz := DefaultCenterHz;
|
||
if FBtnFollow <> nil then FBtnFollow.Active := FFollow;
|
||
if FBtnAuto <> nil then FBtnAuto.Active := FAuto;
|
||
AutoReset;
|
||
ClampWindow;
|
||
// Наведение декодера могло остаться за краем восстановленного окна — его
|
||
// подтянет FOLLOW на первом же тике (PollZoom), отдельной логики не нужно.
|
||
FZoomDirty := True;
|
||
FStripValid := False;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.StoreZoomState;
|
||
// Зовётся каждый тик: так последнее состояние окна попадает в настройки, каким
|
||
// бы путём оно ни поменялось (колесо, drag, кнопки, авто-подтяжка FOLLOW).
|
||
begin
|
||
if FCtl = nil then Exit;
|
||
FCtl.FBcnZoomSpanHz := FSpanHz;
|
||
FCtl.FBcnZoomFollow := FFollow;
|
||
FCtl.FBcnZoomAuto := FAuto;
|
||
if FCtl.BeaconRefHz > 0 then
|
||
FCtl.FBcnZoomOffsetHz := FCenterHz - FCtl.BeaconRefHz;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.PollZoom;
|
||
// Раз/тик: держим окно на месте (или тянем за декодером), просим у контроллера
|
||
// свежий кадр лупы и отдаём водопаду.
|
||
var
|
||
Cnt: Integer;
|
||
Lo, Hi, Dec_: Double;
|
||
GotWf, Fresh: Boolean;
|
||
begin
|
||
if FCtl = nil then Exit;
|
||
|
||
if FCenterHz <= 0 then FCenterHz := DefaultCenterHz;
|
||
// FOLLOW не «ведёт» окно постоянно (картинка ездила бы под рукой), а лишь не
|
||
// даёт потерять наведение: подтягивает окно, когда маркер ушёл за край.
|
||
if FFollow and not FDragging then
|
||
begin
|
||
Dec_ := FCtl.BeaconDecodeFreqHz;
|
||
if (Dec_ > 0) and (Abs(Dec_ - FCenterHz) > FSpanHz * 0.45) then
|
||
begin
|
||
FCenterHz := Dec_;
|
||
FZoomDirty := True;
|
||
end;
|
||
end;
|
||
ClampWindow;
|
||
StoreZoomState;
|
||
|
||
FCtl.SetBeaconZoomWindow(True, FCenterHz, FSpanHz);
|
||
|
||
// Кадр анализатора приходит реже таймера (BCN_ZOOM_FPS) — пересобираем
|
||
// картинку ТОЛЬКО на свежем кадре либо когда переехали маркеры/окно.
|
||
// «Несвежий» кадр всё равно забираем: он нужен как последний нарисованный,
|
||
// пока анализатор наполняет FFT-окно после переарма.
|
||
// Возврат GetBeaconZoomSpectrum = «в кэше был НЕпрочитанный кадр»; это и есть
|
||
// главный триггер перерисовки трассы. Границы окна тут не помощник: пока
|
||
// никто не панорамирует, они не меняются, а пиксели — каждый кадр.
|
||
Fresh := FCtl.GetBeaconZoomSpectrum(FPix, Cnt, Lo, Hi);
|
||
if Fresh then FZoomDirty := True;
|
||
if Cnt > 0 then
|
||
begin
|
||
if (Cnt <> FPixCount) or (Abs(Lo - FViewLo) > 0.01)
|
||
or (Abs(Hi - FViewHi) > 0.01) then
|
||
FZoomDirty := True;
|
||
FPixCount := Cnt;
|
||
FViewLo := Lo;
|
||
FViewHi := Hi;
|
||
FHaveFrame := True;
|
||
end
|
||
else
|
||
FPixCount := 0;
|
||
|
||
// Лок и SNR — часть картинки: по ним видно, попал ли клик в маяк. SNR
|
||
// сравниваем с допуском, иначе дрожание в сотых долях дБ пересобирало бы
|
||
// кадр на каждом тике.
|
||
if FCtl.BeaconState <> FState then FZoomDirty := True;
|
||
if Abs(FCtl.BeaconSNRdB - FSNRdB) > 0.2 then FZoomDirty := True;
|
||
FState := FCtl.BeaconState;
|
||
FSNRdB := FCtl.BeaconSNRdB;
|
||
|
||
if FCtl.BeaconRefHz <> FLastRefHz then FZoomDirty := True;
|
||
if FCtl.BeaconTrackedFreqHz <> FLastTrkHz then FZoomDirty := True;
|
||
if FCtl.BeaconDecodeFreqHz <> FLastDecHz then FZoomDirty := True;
|
||
FLastRefHz := FCtl.BeaconRefHz;
|
||
FLastTrkHz := FCtl.BeaconTrackedFreqHz;
|
||
FLastDecHz := FCtl.BeaconDecodeFreqHz;
|
||
|
||
AutoService(Fresh);
|
||
|
||
GotWf := FCtl.GetBeaconZoomWaterfall(FWfPix, Cnt);
|
||
if GotWf and (Cnt > 0) and (FWf <> nil) then
|
||
begin
|
||
FWf.CenterFreq := (FViewLo + FViewHi) / 2.0;
|
||
FWf.SpanHz := Max(1.0, FViewHi - FViewLo);
|
||
FWf.SetWaterfallData(FWfPix, Cnt);
|
||
if FWf.WaterfallDirty then
|
||
begin
|
||
FWf.DrawWaterfall; // новая строка в кольцо — только тогда и рисуем
|
||
FWf.WaterfallDirty := False;
|
||
if FWfBox <> nil then FWfBox.Invalidate;
|
||
end;
|
||
end;
|
||
|
||
if FZoomDirty then
|
||
begin
|
||
FZoomDirty := False;
|
||
FStripValid := False;
|
||
if FSpecBox <> nil then FSpecBox.Invalidate; // сборку кадра сделает PaintSpec
|
||
end;
|
||
end;
|
||
|
||
function TBeaconScopeForm.XToFreq(X, W: Integer): Double;
|
||
var Lo, Hi: Double;
|
||
begin
|
||
if FHaveFrame then begin Lo := FViewLo; Hi := FViewHi; end
|
||
else begin Lo := FCenterHz - FSpanHz / 2; Hi := FCenterHz + FSpanHz / 2; end;
|
||
if W <= 1 then Exit(Lo);
|
||
Result := Lo + (Hi - Lo) * X / (W - 1);
|
||
end;
|
||
|
||
function TBeaconScopeForm.FreqToX(F: Double; W: Integer): Integer;
|
||
var Lo, Hi: Double;
|
||
begin
|
||
if FHaveFrame then begin Lo := FViewLo; Hi := FViewHi; end
|
||
else begin Lo := FCenterHz - FSpanHz / 2; Hi := FCenterHz + FSpanHz / 2; end;
|
||
if Hi - Lo <= 0 then Exit(-1);
|
||
Result := Round((F - Lo) / (Hi - Lo) * (W - 1));
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.ZoomBy(Factor: Double; AnchorX, W: Integer);
|
||
// Зум вокруг точки под курсором: частота под ней остаётся на месте.
|
||
var
|
||
FAnchor, NewSpan, Frac: Double;
|
||
begin
|
||
NewSpan := EnsureRange(FSpanHz * Factor, BCN_SPAN_MIN, BCN_SPAN_MAX);
|
||
if Abs(NewSpan - FSpanHz) < 1E-6 then Exit;
|
||
if (AnchorX >= 0) and (W > 1) then
|
||
begin
|
||
FAnchor := XToFreq(AnchorX, W);
|
||
Frac := AnchorX / (W - 1);
|
||
FCenterHz := FAnchor - (Frac - 0.5) * NewSpan;
|
||
end;
|
||
FSpanHz := NewSpan;
|
||
ClampWindow;
|
||
FZoomDirty := True;
|
||
end;
|
||
|
||
function TBeaconScopeForm.FrameToText(const Frame: TBeaconFrame): string;
|
||
// AO-40 кадр QO-100 = ASCII-текст (space-padded), последние 2 байта = CRC-16.
|
||
// Как gr-satellites qo100.parse: отбрасываем CRC, печатные ASCII, строки по 64.
|
||
var
|
||
i, b: Integer;
|
||
line: string;
|
||
begin
|
||
Result := '';
|
||
i := 0;
|
||
while i <= 253 do
|
||
begin
|
||
line := '';
|
||
for b := i to Min(i + 63, 253) do
|
||
if (Frame[b] >= 32) and (Frame[b] < 127) then line := line + Chr(Frame[b])
|
||
else line := line + ' ';
|
||
Result := Result + TrimRight(line) + LineEnding;
|
||
Inc(i, 64);
|
||
end;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Лупа наведения: мышь и кнопки
|
||
// ===========================================================================
|
||
|
||
procedure TBeaconScopeForm.SpecMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
begin
|
||
if Button <> mbLeft then Exit;
|
||
FDragging := True;
|
||
FDragMoved := False;
|
||
FDragX := X;
|
||
FDragCenter := FCenterHz;
|
||
FCursorX := X;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.SpecMouseMove(Sender: TObject; Shift: TShiftState;
|
||
X, Y: Integer);
|
||
// Тянем с зажатой ЛКМ — панорамируем окно; отпустили не сдвинув — это клик
|
||
// наведения (см. SpecMouseUp).
|
||
var
|
||
W: Integer;
|
||
Lo, Hi: Double;
|
||
begin
|
||
FCursorX := X;
|
||
W := TControl(Sender).Width;
|
||
if FDragging then
|
||
begin
|
||
if Abs(X - FDragX) > DpiScale(3) then FDragMoved := True;
|
||
if FDragMoved and (W > 1) then
|
||
begin
|
||
if FHaveFrame then begin Lo := FViewLo; Hi := FViewHi; end
|
||
else begin Lo := FCenterHz - FSpanHz / 2; Hi := FCenterHz + FSpanHz / 2; end;
|
||
FCenterHz := FDragCenter - (X - FDragX) * (Hi - Lo) / (W - 1);
|
||
ClampWindow;
|
||
FZoomDirty := True;
|
||
end;
|
||
end;
|
||
if FSpecBox <> nil then FSpecBox.Invalidate;
|
||
if FWfBox <> nil then FWfBox.Invalidate;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.SpecMouseUp(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
// Клик = навести декодер на эту частоту. Ровно тот же путь, что у клика по
|
||
// главному спектру (MainForm.PbSpectrumMouseDown): BeaconSeedAtHz сам включит
|
||
// лок, если он был выключен.
|
||
var W: Integer;
|
||
begin
|
||
if Button <> mbLeft then Exit;
|
||
W := TControl(Sender).Width;
|
||
if FDragging and not FDragMoved and (FCtl <> nil) and (W > 1) then
|
||
FCtl.BeaconSeedAtHz(XToFreq(X, W));
|
||
FDragging := False;
|
||
FDragMoved := False;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.SpecMouseLeave(Sender: TObject);
|
||
begin
|
||
FCursorX := -1;
|
||
FDragging := False;
|
||
if FSpecBox <> nil then FSpecBox.Invalidate;
|
||
if FWfBox <> nil then FWfBox.Invalidate;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.SpecWheel(Sender: TObject; Shift: TShiftState;
|
||
WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
|
||
// MousePos у колеса — экранные координаты, поэтому якорь берём из последнего
|
||
// MouseMove (FCursorX), он всегда в координатах виджета.
|
||
begin
|
||
if WheelDelta = 0 then Exit;
|
||
if WheelDelta > 0 then
|
||
ZoomBy(1.0 / BCN_ZOOM_STEP, FCursorX, TControl(Sender).Width)
|
||
else
|
||
ZoomBy(BCN_ZOOM_STEP, FCursorX, TControl(Sender).Width);
|
||
Handled := True;
|
||
if FSpecBox <> nil then FSpecBox.Invalidate;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.SpecDblClick(Sender: TObject);
|
||
// Двойной клик — центрировать окно на точке под курсором.
|
||
var W: Integer;
|
||
begin
|
||
W := TControl(Sender).Width;
|
||
if (FCursorX >= 0) and (W > 1) then
|
||
begin
|
||
FCenterHz := XToFreq(FCursorX, W);
|
||
ClampWindow;
|
||
FZoomDirty := True;
|
||
end;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.BtnZoomInClick(Sender: TObject);
|
||
begin
|
||
ZoomBy(1.0 / BCN_ZOOM_STEP, -1, 0);
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.BtnZoomOutClick(Sender: TObject);
|
||
begin
|
||
ZoomBy(BCN_ZOOM_STEP, -1, 0);
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.BtnResetClick(Sender: TObject);
|
||
begin
|
||
FSpanHz := BCN_SPAN_DEF;
|
||
FCenterHz := DefaultCenterHz;
|
||
ClampWindow;
|
||
FZoomDirty := True;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.BtnFollowClick(Sender: TObject);
|
||
begin
|
||
FFollow := not FFollow;
|
||
if FBtnFollow <> nil then FBtnFollow.Active := FFollow;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.BtnAutoClick(Sender: TObject);
|
||
begin
|
||
FAuto := not FAuto;
|
||
if FBtnAuto <> nil then FBtnAuto.Active := FAuto;
|
||
AutoReset;
|
||
FAutoCooldown := 0; // включили руками — ищем сразу, без выдержки
|
||
FZoomDirty := True; // в строке захвата появляется/пропадает пометка AUTO
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.DoShow;
|
||
begin
|
||
inherited DoShow;
|
||
RestoreZoomState;
|
||
// Палитра/гамма водопада — те же, что выбраны для главного (настройки живут
|
||
// в контроллере), иначе лупа выглядела бы чужой панелью.
|
||
if (FWf <> nil) and (FCtl <> nil) then
|
||
begin
|
||
FWf.WfPalette := FCtl.FWfPalette;
|
||
FWf.WfGamma := FCtl.FWfGamma;
|
||
end;
|
||
LayoutTunePanel;
|
||
FZoomDirty := True;
|
||
FStripValid := False;
|
||
if FTimer <> nil then FTimer.Enabled := True;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.DoHide;
|
||
begin
|
||
// Окно закрыли — гасим analyzer лупы: он второй по счёту на том же IQ,
|
||
// держать его без зрителя незачем. Таймер тоже стоп: MainForm освобождает
|
||
// контроллер раньше, чем эту форму, и тик по мёртвому FCtl был бы фатален.
|
||
if FTimer <> nil then FTimer.Enabled := False;
|
||
if FCtl <> nil then FCtl.SetBeaconZoomWindow(False, 0, 0);
|
||
inherited DoHide;
|
||
end;
|
||
|
||
destructor TBeaconScopeForm.Destroy;
|
||
// Контроллер здесь НЕ трогаем: форму владелец (MainForm) освобождает уже после
|
||
// своего FormDestroy, где FController освобождён — analyzer лупы к этому моменту
|
||
// умер вместе с движком. Гасит лупу DoHide, он же отрабатывает при закрытии окна.
|
||
begin
|
||
FWf.Free;
|
||
FStrip.Free;
|
||
FScope.Free;
|
||
FScopeChrome.Free;
|
||
inherited Destroy;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.PollFrame;
|
||
// Раз/тик: если декодер выдал НОВЫЙ кадр (S.Frames вырос) — печатаем бюллетень.
|
||
var
|
||
S: TBeaconScope;
|
||
Frame: TBeaconFrame;
|
||
rs: Integer;
|
||
begin
|
||
if FCtl = nil then Exit;
|
||
if not FCtl.GetBeaconScope(S) then Exit;
|
||
if S.Frames <= FLastFrames then Exit;
|
||
FLastFrames := S.Frames;
|
||
if not FCtl.GetBeaconFrame(Frame, rs) then Exit;
|
||
FMemo.Append(Format('--- frame %d (RS err %d) ---', [S.Frames, rs]));
|
||
FMemo.Append(FrameToText(Frame));
|
||
while FMemo.Lines.Count > 400 do FMemo.Lines.Delete(0); // ограничиваем рост
|
||
FMemo.SelStart := Length(FMemo.Text); // автоскролл вниз
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Лупа наведения: отрисовка
|
||
// ===========================================================================
|
||
|
||
function FormatBcnFreq(Hz: Double): string;
|
||
// 10489750123 → '10489.750.123'
|
||
var MHz: Int64; KHz, Rest: Integer;
|
||
begin
|
||
MHz := Trunc(Hz / 1000000);
|
||
KHz := Trunc((Hz - MHz * 1000000) / 1000);
|
||
Rest := Trunc(Hz) mod 1000;
|
||
Result := Format('%d.%3.3d.%3.3d', [MHz, KHz, Rest]);
|
||
end;
|
||
|
||
function FormatTick(Hz: Double; StepHz: Double): string;
|
||
// Подпись деления линейки: килогерцы (+ герцы на мелком шаге). Полная частота
|
||
// висит в углу карточки, так что здесь важны только младшие разряды.
|
||
var KHz, Rest: Integer;
|
||
begin
|
||
KHz := (Trunc(Hz) div 1000) mod 1000;
|
||
Rest := Trunc(Hz) mod 1000;
|
||
if StepHz >= 1000 then Result := Format('%3.3d', [KHz])
|
||
else Result := Format('%3.3d.%3.3d', [KHz, Rest]);
|
||
end;
|
||
|
||
function NiceStep(SpanHz: Double; WantTicks: Integer): Double;
|
||
// Ближайший «круглый» шаг (1/2/5·10^k), дающий примерно WantTicks делений.
|
||
var Raw, Mag, Norm: Double;
|
||
begin
|
||
Raw := SpanHz / Max(1, WantTicks);
|
||
if Raw <= 0 then Exit(1.0);
|
||
Mag := Power(10, Floor(Log10(Raw)));
|
||
Norm := Raw / Mag;
|
||
if Norm < 1.5 then Result := Mag
|
||
else if Norm < 3.5 then Result := 2 * Mag
|
||
else if Norm < 7.5 then Result := 5 * Mag
|
||
else Result := 10 * Mag;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.DrawZoomStrip;
|
||
// Полная сборка кадра лупы в офскрин FStrip. Зовётся ТОЛЬКО по FZoomDirty
|
||
// (новый кадр анализатора, сдвиг маркеров/окна, ресайз, смена темы) — так же,
|
||
// как MainForm гоняет FSpecView.DrawSpectrum по FSpectrumDirty. Курсор сюда не
|
||
// входит: он живёт в PaintSpec, чтобы движение мыши не пересобирало кадр.
|
||
var
|
||
C: TCanvas;
|
||
W, H, PlotH, RulerH, i, X, Y, Cnt: Integer;
|
||
Pts: array of TPoint;
|
||
Lo, Hi, Span, dB, MaxDB, Step, F, T: Double;
|
||
DecHz, RefHz, TrkHz: Double;
|
||
Txt: string;
|
||
Sorted: array of Double;
|
||
TmpD: Double;
|
||
a, b: Integer;
|
||
Locked: Boolean;
|
||
Sign: string;
|
||
|
||
procedure SetFont(ASize: Integer; AStyle: TFontStyles = []);
|
||
begin
|
||
C.Font.Assign(Self.Font);
|
||
C.Font.Size := ASize;
|
||
C.Font.Style := AStyle;
|
||
end;
|
||
|
||
procedure VLine(FreqHz: Double; AColor: TColor; Dotted: Boolean);
|
||
begin
|
||
X := FreqToX(FreqHz, W);
|
||
if (X < 0) or (X >= W) then Exit;
|
||
C.Pen.Color := AColor;
|
||
C.Pen.Width := 1;
|
||
if Dotted then C.Pen.Style := psDot else C.Pen.Style := psSolid;
|
||
C.MoveTo(X, 0);
|
||
C.LineTo(X, PlotH);
|
||
C.Pen.Style := psSolid;
|
||
end;
|
||
|
||
begin
|
||
W := FSpecBox.Width;
|
||
H := FSpecBox.Height;
|
||
if (W <= 4) or (H <= 8) then Exit;
|
||
if (FStrip.Width <> W) or (FStrip.Height <> H) then
|
||
FStrip.SetSize(W, H);
|
||
C := FStrip.Canvas;
|
||
RulerH := DpiScale(15);
|
||
PlotH := H - RulerH;
|
||
|
||
// Фон + рамка
|
||
C.Brush.Style := bsSolid;
|
||
C.Brush.Color := FTheme.SpecGradBot;
|
||
C.FillRect(0, 0, W, H);
|
||
C.Brush.Color := FTheme.Panel;
|
||
C.FillRect(0, PlotH, W, H);
|
||
|
||
if FHaveFrame then begin Lo := FViewLo; Hi := FViewHi; end
|
||
else begin Lo := FCenterHz - FSpanHz / 2; Hi := FCenterHz + FSpanHz / 2; end;
|
||
Span := Hi - Lo;
|
||
Cnt := FPixCount;
|
||
|
||
// --- Шкала дБ: верх по максимуму кадра, низ по медиане (шумовой пол) ---
|
||
// Границы тянутся к цели плавно: иначе шкала прыгала бы на каждой посылке
|
||
// соседней SSB-станции, а маяк — то, что должно стоять на месте.
|
||
if Cnt > 1 then
|
||
begin
|
||
MaxDB := -400;
|
||
for i := 0 to Cnt - 1 do
|
||
if FPix[i] > MaxDB then MaxDB := FPix[i];
|
||
// Медиана считается по прореженной выборке (каждая 8-я точка кадра) —
|
||
// сортировка вставками по ~128 значениям дешевле полной по 1024.
|
||
SetLength(Sorted, (Cnt + 7) div 8);
|
||
for i := 0 to High(Sorted) do
|
||
Sorted[i] := FPix[Min(i * 8, Cnt - 1)];
|
||
for a := 1 to High(Sorted) do
|
||
begin
|
||
TmpD := Sorted[a];
|
||
b := a - 1;
|
||
while (b >= 0) and (Sorted[b] > TmpD) do
|
||
begin
|
||
Sorted[b + 1] := Sorted[b];
|
||
Dec(b);
|
||
end;
|
||
Sorted[b + 1] := TmpD;
|
||
end;
|
||
dB := Sorted[Length(Sorted) div 2]; // ≈ шумовой пол
|
||
if MaxDB - dB < 30 then MaxDB := dB + 30;
|
||
FSpecHi := FSpecHi + 0.2 * ((MaxDB + 6) - FSpecHi);
|
||
FSpecLo := FSpecLo + 0.2 * ((dB - 12) - FSpecLo);
|
||
if FSpecHi - FSpecLo < 25 then FSpecHi := FSpecLo + 25;
|
||
end;
|
||
|
||
// --- Сетка ---
|
||
C.Pen.Style := psSolid;
|
||
C.Pen.Width := 1;
|
||
C.Pen.Color := FTheme.SpecGrid;
|
||
for i := 1 to 3 do
|
||
begin
|
||
Y := PlotH * i div 4;
|
||
C.MoveTo(0, Y); C.LineTo(W, Y);
|
||
end;
|
||
Step := NiceStep(Span, 6);
|
||
F := Ceil(Lo / Step) * Step;
|
||
while F < Hi do
|
||
begin
|
||
X := FreqToX(F, W);
|
||
if (X > 0) and (X < W) then
|
||
begin
|
||
C.Pen.Color := FTheme.SpecGrid;
|
||
C.MoveTo(X, 0); C.LineTo(X, PlotH);
|
||
end;
|
||
F := F + Step;
|
||
end;
|
||
|
||
DecHz := 0; RefHz := 0; TrkHz := 0;
|
||
if FCtl <> nil then
|
||
begin
|
||
DecHz := FCtl.BeaconDecodeFreqHz;
|
||
RefHz := FCtl.BeaconRefHz;
|
||
TrkHz := FCtl.BeaconTrackedFreqHz;
|
||
end;
|
||
|
||
// --- Трасса ---
|
||
if Cnt > 1 then
|
||
begin
|
||
SetLength(Pts, W + 2);
|
||
for X := 0 to W - 1 do
|
||
begin
|
||
// Кадр лупы (1024 точки) шире полотна — берём максимум группы, чтобы
|
||
// узкая несущая маяка не потерялась между пикселями.
|
||
a := Trunc(X * Cnt / W);
|
||
b := Trunc((X + 1) * Cnt / W);
|
||
if b <= a then b := a + 1;
|
||
if b > Cnt then b := Cnt;
|
||
dB := FPix[a];
|
||
for i := a + 1 to b - 1 do
|
||
if FPix[i] > dB then dB := FPix[i];
|
||
T := (dB - FSpecLo) / Max(1.0, FSpecHi - FSpecLo);
|
||
Y := PlotH - 1 - Round(EnsureRange(T, 0.0, 1.0) * (PlotH - 2));
|
||
Pts[X] := Point(X, Y);
|
||
end;
|
||
// Заливка под трассой
|
||
Pts[W] := Point(W - 1, PlotH);
|
||
Pts[W + 1] := Point(0, PlotH);
|
||
C.Brush.Style := bsSolid;
|
||
C.Brush.Color := FTheme.SpecGradTop;
|
||
C.Pen.Style := psClear;
|
||
C.Polygon(Pts);
|
||
C.Pen.Style := psSolid;
|
||
C.Pen.Color := FTheme.SpecLine;
|
||
C.Pen.Width := 1;
|
||
C.MoveTo(Pts[0].X, Pts[0].Y);
|
||
for X := 1 to W - 1 do
|
||
C.LineTo(Pts[X].X, Pts[X].Y);
|
||
end
|
||
else
|
||
begin
|
||
SetFont(9);
|
||
C.Brush.Style := bsClear;
|
||
C.Font.Color := FTheme.TextDim;
|
||
Txt := 'waiting for IQ…';
|
||
C.TextOut((W - C.TextWidth(Txt)) div 2, PlotH div 2 - DpiScale(8), Txt);
|
||
end;
|
||
|
||
// --- Маркеры: один в один с главным спектром (DrawBeaconMarkersRaw/GL) ---
|
||
// Опорная (пунктир) + центроид, и три линии полосы приёма декодера. Заливки
|
||
// полосы намеренно нет: там так же — «дешевле для CPU и единый вид с GL».
|
||
if RefHz > 0 then VLine(RefHz, BCN_CLR_REF, True);
|
||
if TrkHz > 0 then VLine(TrkHz, BCN_CLR_TRK, False);
|
||
if DecHz > 0 then
|
||
begin
|
||
// Полоса приёма показывает захват сама: в поиске грани пунктирные, на локе
|
||
// — сплошные и стянуты скобой. Так «попал/не попал» читается прямо здесь,
|
||
// не переводя взгляд на плитки метрик под графиком.
|
||
Locked := FState = bsLock;
|
||
VLine(DecHz - BCN_DEC_BW_HZ, BCN_CLR_DEC, not Locked);
|
||
VLine(DecHz + BCN_DEC_BW_HZ, BCN_CLR_DEC, not Locked);
|
||
VLine(DecHz, BCN_CLR_DEC, not Locked);
|
||
if Locked then
|
||
begin
|
||
a := EnsureRange(FreqToX(DecHz - BCN_DEC_BW_HZ, W), 0, W - 1);
|
||
b := EnsureRange(FreqToX(DecHz + BCN_DEC_BW_HZ, W), 0, W - 1);
|
||
if b > a then
|
||
begin
|
||
Y := DpiScale(40); // ниже строки захвата, чтобы не наехать на текст
|
||
C.Pen.Color := BCN_CLR_DEC;
|
||
C.Pen.Style := psSolid;
|
||
C.Line(a, Y, b, Y);
|
||
C.Line(a, Y, a, Y + DpiScale(4));
|
||
C.Line(b, Y, b, Y + DpiScale(4));
|
||
end;
|
||
end;
|
||
end;
|
||
|
||
// --- Линейка частот ---
|
||
SetFont(7);
|
||
C.Brush.Style := bsClear;
|
||
C.Pen.Color := FTheme.Border;
|
||
C.MoveTo(0, PlotH); C.LineTo(W, PlotH);
|
||
F := Ceil(Lo / Step) * Step;
|
||
while F < Hi do
|
||
begin
|
||
X := FreqToX(F, W);
|
||
if (X > 0) and (X < W) then
|
||
begin
|
||
C.Pen.Color := FTheme.Border;
|
||
C.MoveTo(X, PlotH); C.LineTo(X, PlotH + DpiScale(3));
|
||
Txt := FormatTick(F, Step);
|
||
C.Font.Color := FTheme.RulerText;
|
||
a := X - C.TextWidth(Txt) div 2;
|
||
if (a > 0) and (a + C.TextWidth(Txt) < W) then
|
||
C.TextOut(a, PlotH + DpiScale(2), Txt);
|
||
end;
|
||
F := F + Step;
|
||
end;
|
||
|
||
// --- Показания в углах ---
|
||
SetFont(8, [fsBold]);
|
||
C.Brush.Style := bsClear;
|
||
C.Font.Color := FTheme.Text;
|
||
C.TextOut(DpiScale(6), DpiScale(4), FormatBcnFreq((Lo + Hi) / 2));
|
||
SetFont(7);
|
||
C.Font.Color := FTheme.TextDim;
|
||
if (FCtl <> nil) and (FCtl.BeaconZoomBinHz > 0) then
|
||
Txt := Format('span %s · %.1f Hz/bin',
|
||
[IfThen(Span >= 10000, Format('%.0f kHz', [Span / 1000]),
|
||
Format('%.1f kHz', [Span / 1000])),
|
||
FCtl.BeaconZoomBinHz])
|
||
else
|
||
Txt := Format('span %.1f kHz', [Span / 1000]);
|
||
C.TextOut(W - DpiScale(6) - C.TextWidth(Txt), DpiScale(5), Txt);
|
||
|
||
// --- Строка захвата: состояние + SNR + расхождение с опорной ---
|
||
// Δ = измеренная частота маяка минус опорная, то есть ровно расстояние между
|
||
// оранжевым и зелёным маркерами. Это невязка, которую контур стекает в
|
||
// LOError: пока она больше дедбэнда, лок ещё подкручивает LO.
|
||
SetFont(7);
|
||
case FState of
|
||
bsLock: begin Txt := 'LOCK'; C.Font.Color := FTheme.MeterOn; end;
|
||
bsSearch: begin Txt := 'SEARCH'; C.Font.Color := FTheme.Amber; end;
|
||
else
|
||
Txt := 'BEACON OFF';
|
||
C.Font.Color := FTheme.TextDim;
|
||
end;
|
||
if (FState <> bsIdle) and (FSNRdB > -50) then
|
||
Txt := Txt + Format(' · %.1f dB', [FSNRdB]);
|
||
// Автопоиск помечаем только пока он реально работает: на локе он молчит, и
|
||
// светить «AUTO» там значило бы обещать действие, которого не происходит.
|
||
if FAuto and (FState <> bsLock) then
|
||
Txt := Txt + ' · AUTO';
|
||
C.TextOut(DpiScale(6), DpiScale(21), Txt);
|
||
a := DpiScale(6) + C.TextWidth(Txt) + DpiScale(10);
|
||
|
||
if (FState <> bsIdle) and (TrkHz > 0) and (RefHz > 0) then
|
||
begin
|
||
T := TrkHz - RefHz;
|
||
// Знак ставим руками: флага '+' в Format Object Pascal нет (это printf'изм),
|
||
// '%+.0f' молча съедается и в строку попадает голый остаток спецификатора.
|
||
Sign := IfThen(T >= 0, '+', '');
|
||
if Abs(T) < 1000 then Txt := Format('Δ %s%.0f Hz', [Sign, T])
|
||
else Txt := Format('Δ %s%.2f kHz', [Sign, T / 1000]);
|
||
// Внутри дедбэнда контура (20 Гц) маяк считается стоящим на месте.
|
||
if Abs(T) <= 20.0 then C.Font.Color := FTheme.MeterOn
|
||
else C.Font.Color := FTheme.Amber;
|
||
C.TextOut(a, DpiScale(21), Txt);
|
||
end;
|
||
|
||
// Рамка карточки поверх всего
|
||
C.Brush.Style := bsClear;
|
||
C.Pen.Style := psSolid;
|
||
C.Pen.Color := FTheme.Border;
|
||
C.Rectangle(0, 0, W, H);
|
||
|
||
FStripValid := True;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.PaintSpec(Sender: TObject);
|
||
// Только блит готового кадра + курсор поверх. Курсор рисуется здесь (а не в
|
||
// DrawZoomStrip) по образцу TWaterfallView.PaintWaterfall/DrawMarkerLine:
|
||
// движение мыши стоит блит и одну линию, а не пересборку кадра.
|
||
var
|
||
C: TCanvas;
|
||
W, H, PlotH, TxtX: Integer;
|
||
Txt: string;
|
||
begin
|
||
W := FSpecBox.Width;
|
||
H := FSpecBox.Height;
|
||
if (W <= 4) or (H <= 8) then Exit;
|
||
if (not FStripValid) or (FStrip.Width <> W) or (FStrip.Height <> H) then
|
||
DrawZoomStrip;
|
||
C := FSpecBox.Canvas;
|
||
C.Draw(0, 0, FStrip);
|
||
if (FCursorX < 0) or (FCursorX >= W) then Exit;
|
||
|
||
PlotH := H - DpiScale(15);
|
||
C.Pen.Color := FTheme.SpecVfoCursor;
|
||
C.Pen.Style := psDot;
|
||
C.MoveTo(FCursorX, 0); C.LineTo(FCursorX, PlotH);
|
||
C.Pen.Style := psSolid;
|
||
C.Font.Assign(Self.Font);
|
||
C.Font.Size := 7;
|
||
C.Brush.Style := bsSolid;
|
||
C.Brush.Color := FTheme.Panel;
|
||
C.Font.Color := FTheme.Text;
|
||
Txt := ' ' + FormatBcnFreq(XToFreq(FCursorX, W)) + ' ';
|
||
TxtX := FCursorX + DpiScale(4);
|
||
if TxtX + C.TextWidth(Txt) > W then
|
||
TxtX := FCursorX - DpiScale(4) - C.TextWidth(Txt);
|
||
C.TextOut(TxtX, PlotH - DpiScale(14), Txt);
|
||
C.Brush.Style := bsClear;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.PaintWf(Sender: TObject);
|
||
var
|
||
C: TCanvas;
|
||
W, H, X: Integer;
|
||
DecHz: Double;
|
||
begin
|
||
if FWf = nil then Exit;
|
||
FWf.PaintWaterfall(Sender);
|
||
C := FWfBox.Canvas;
|
||
W := FWfBox.Width;
|
||
H := FWfBox.Height;
|
||
if (W <= 0) or (H <= 0) then Exit;
|
||
// Маркер наведения продолжается на водопад — так видно, держится ли след
|
||
// маяка под полосой приёма.
|
||
DecHz := 0;
|
||
if FCtl <> nil then DecHz := FCtl.BeaconDecodeFreqHz;
|
||
if DecHz > 0 then
|
||
begin
|
||
X := FreqToX(DecHz, W);
|
||
if (X >= 0) and (X < W) then
|
||
begin
|
||
C.Pen.Style := psDot;
|
||
C.Pen.Color := BCN_CLR_DEC;
|
||
C.MoveTo(X, 0); C.LineTo(X, H);
|
||
C.Pen.Style := psSolid;
|
||
end;
|
||
end;
|
||
if (FCursorX >= 0) and (FCursorX < W) then
|
||
begin
|
||
C.Pen.Style := psDot;
|
||
C.Pen.Color := FTheme.SpecVfoCursor;
|
||
C.MoveTo(FCursorX, 0); C.LineTo(FCursorX, H);
|
||
C.Pen.Style := psSolid;
|
||
end;
|
||
C.Brush.Style := bsClear;
|
||
C.Pen.Color := FTheme.Border;
|
||
C.Rectangle(0, 0, W, H);
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Констелляция и метрики: геометрия / статичная подложка / кадр
|
||
// ===========================================================================
|
||
// Рендер разложен на три слоя по образцу TSpectrumView (FGridBitmap → кадр →
|
||
// блит): неподвижная обвязка (карточки, оси, кружки, рамки и подписи плиток)
|
||
// живёт в отдельном битмапе и пересобирается только на ресайз/смену темы,
|
||
// кадр собирается по FScopeValid, а OnPaint лишь копирует готовое. Раньше вся
|
||
// эта геометрия — два RoundRect карточек, восемь RoundRect плиток и десяток
|
||
// TextOut — рисовалась заново 25 раз в секунду прямо в обработчике Paint.
|
||
|
||
procedure TBeaconScopeForm.ScopeLayout(W, H: Integer;
|
||
out GraphR, MetricsR, PlotR: TRect; out cx, cy, r: Integer;
|
||
out Wide: Boolean);
|
||
var
|
||
M, Gap, GraphSize: Integer;
|
||
begin
|
||
M := DpiScale(16);
|
||
Gap := DpiScale(14);
|
||
Wide := W >= DpiScale(600);
|
||
if Wide then
|
||
begin
|
||
GraphSize := Min(H - 2 * M, (W - 3 * M) div 2);
|
||
GraphR := Rect(M, M, M + GraphSize, H - M);
|
||
MetricsR := Rect(GraphR.Right + Gap, M, W - M, H - M);
|
||
end
|
||
else
|
||
begin
|
||
GraphSize := Max(DpiScale(74), H - DpiScale(154) - 3 * M);
|
||
GraphR := Rect(M, M, W - M, M + GraphSize);
|
||
MetricsR := Rect(M, GraphR.Bottom + Gap, W - M, H - M);
|
||
end;
|
||
|
||
// Квадрат констелляции центрируется внутри своей карточки.
|
||
GraphSize := Min(GraphR.Right - GraphR.Left - DpiScale(28),
|
||
GraphR.Bottom - GraphR.Top - DpiScale(48));
|
||
GraphSize := Max(DpiScale(40), GraphSize);
|
||
PlotR.Left := GraphR.Left +
|
||
(GraphR.Right - GraphR.Left - GraphSize) div 2;
|
||
PlotR.Top := GraphR.Top + DpiScale(34) +
|
||
(GraphR.Bottom - GraphR.Top - DpiScale(40) - GraphSize) div 2;
|
||
PlotR.Right := PlotR.Left + GraphSize;
|
||
PlotR.Bottom := PlotR.Top + GraphSize;
|
||
cx := (PlotR.Left + PlotR.Right) div 2;
|
||
cy := (PlotR.Top + PlotR.Bottom) div 2;
|
||
r := GraphSize div 2 - DpiScale(8);
|
||
end;
|
||
|
||
function TBeaconScopeForm.MetricTileRect(const MetricsR: TRect; Wide: Boolean;
|
||
n: Integer): TRect;
|
||
var
|
||
Cols, Rows, Col, Row, TileW, TileH, X, Y, CardGap: Integer;
|
||
begin
|
||
if Wide then begin Cols := 2; Rows := 4; end
|
||
else begin Cols := 4; Rows := 2; end;
|
||
CardGap := DpiScale(8);
|
||
TileW := (MetricsR.Right - MetricsR.Left - DpiScale(28)
|
||
- (Cols - 1) * CardGap) div Cols;
|
||
TileH := (MetricsR.Bottom - MetricsR.Top - DpiScale(70)
|
||
- (Rows - 1) * CardGap) div Rows;
|
||
Col := n mod Cols;
|
||
Row := n div Cols;
|
||
X := MetricsR.Left + DpiScale(14) + Col * (TileW + CardGap);
|
||
Y := MetricsR.Top + DpiScale(34) + Row * (TileH + CardGap);
|
||
Result := Rect(X, Y, X + TileW, Y + TileH);
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.BuildScopeChrome(W, H: Integer);
|
||
// Всё, что не меняется от кадра к кадру. Значения метрик и точки констелляции
|
||
// сюда НЕ входят — они ложатся поверх в DrawScope.
|
||
const
|
||
LABELS: array[0..7] of string =
|
||
('CARRIER', 'SYMBOL', 'SNR', 'SYMBOL RATE',
|
||
'TUNE OFFSET', 'RESIDUAL', 'FRAMES', 'FEC');
|
||
var
|
||
C: TCanvas;
|
||
GraphR, MetricsR, PlotR, TileR: TRect;
|
||
cx, cy, r, n: Integer;
|
||
Wide: Boolean;
|
||
|
||
procedure SetUIFont(ASize: Integer; AStyle: TFontStyles = []);
|
||
begin
|
||
C.Font.Assign(Self.Font);
|
||
C.Font.Size := ASize;
|
||
C.Font.Style := AStyle;
|
||
end;
|
||
|
||
procedure DrawCard(const AR: TRect; const ATitle: string);
|
||
begin
|
||
C.Brush.Style := bsSolid;
|
||
C.Brush.Color := FTheme.Panel;
|
||
C.Pen.Style := psSolid;
|
||
C.Pen.Width := 1;
|
||
C.Pen.Color := FTheme.Border;
|
||
C.RoundRect(AR.Left, AR.Top, AR.Right, AR.Bottom,
|
||
DpiScale(8), DpiScale(8));
|
||
SetUIFont(8, [fsBold]);
|
||
C.Font.Color := FTheme.TextDim;
|
||
C.Brush.Style := bsClear;
|
||
C.TextOut(AR.Left + DpiScale(14), AR.Top + DpiScale(10), ATitle);
|
||
end;
|
||
|
||
begin
|
||
FScopeChrome.SetSize(W, H);
|
||
FChromeW := W;
|
||
FChromeH := H;
|
||
C := FScopeChrome.Canvas;
|
||
|
||
C.Brush.Style := bsSolid;
|
||
C.Brush.Color := FTheme.BG;
|
||
C.FillRect(0, 0, W, H);
|
||
|
||
ScopeLayout(W, H, GraphR, MetricsR, PlotR, cx, cy, r, Wide);
|
||
DrawCard(GraphR, 'CONSTELLATION');
|
||
DrawCard(MetricsR, 'DECODER STATUS');
|
||
|
||
// Поле констелляции: рамка, оси, единичная/половинная окружности и две
|
||
// «идеальные» точки BPSK на оси I.
|
||
C.Brush.Style := bsSolid;
|
||
C.Brush.Color := FTheme.BG;
|
||
C.Pen.Style := psSolid;
|
||
C.Pen.Color := FTheme.Border;
|
||
C.Rectangle(PlotR.Left, PlotR.Top, PlotR.Right, PlotR.Bottom);
|
||
C.Pen.Color := FTheme.SpecGrid;
|
||
C.Line(cx - r, cy, cx + r, cy);
|
||
C.Line(cx, cy - r, cx, cy + r);
|
||
C.Brush.Style := bsClear;
|
||
C.Ellipse(cx - r, cy - r, cx + r, cy + r);
|
||
C.Ellipse(cx - r div 2, cy - r div 2, cx + r div 2, cy + r div 2);
|
||
C.Pen.Color := FTheme.TextDim;
|
||
C.Ellipse(cx + r div 2 - DpiScale(3), cy - DpiScale(3),
|
||
cx + r div 2 + DpiScale(3), cy + DpiScale(3));
|
||
C.Ellipse(cx - r div 2 - DpiScale(3), cy - DpiScale(3),
|
||
cx - r div 2 + DpiScale(3), cy + DpiScale(3));
|
||
|
||
// Плитки метрик: фон, рамка и подпись. Значение — динамика, поверх.
|
||
for n := 0 to 7 do
|
||
begin
|
||
TileR := MetricTileRect(MetricsR, Wide, n);
|
||
C.Brush.Style := bsSolid;
|
||
C.Brush.Color := FTheme.BG;
|
||
C.Pen.Style := psSolid;
|
||
C.Pen.Color := FTheme.Border;
|
||
C.RoundRect(TileR.Left, TileR.Top, TileR.Right, TileR.Bottom,
|
||
DpiScale(5), DpiScale(5));
|
||
C.Brush.Style := bsClear;
|
||
SetUIFont(8);
|
||
C.Font.Color := FTheme.TextDim;
|
||
C.TextOut(TileR.Left + DpiScale(9), TileR.Top + DpiScale(6), LABELS[n]);
|
||
end;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.DrawScope;
|
||
var
|
||
C: TCanvas;
|
||
W, H, cx, cy, r, i, px, py, n: Integer;
|
||
GraphR, MetricsR, PlotR, TileR: TRect;
|
||
S: TBeaconScope;
|
||
ok, Wide: Boolean;
|
||
Values: array[0..7] of string;
|
||
Colors: array[0..7] of TColor;
|
||
TechText: string;
|
||
|
||
procedure SetUIFont(ASize: Integer; AStyle: TFontStyles = []);
|
||
begin
|
||
C.Font.Assign(Self.Font);
|
||
C.Font.Size := ASize;
|
||
C.Font.Style := AStyle;
|
||
end;
|
||
|
||
begin
|
||
W := FBox.Width; H := FBox.Height;
|
||
if (W <= 0) or (H <= 0) then Exit;
|
||
if (FScope.Width <> W) or (FScope.Height <> H) then FScope.SetSize(W, H);
|
||
if (FChromeW <> W) or (FChromeH <> H) then BuildScopeChrome(W, H);
|
||
|
||
C := FScope.Canvas;
|
||
C.Draw(0, 0, FScopeChrome); // подложка = заодно стирание прошлого кадра
|
||
ScopeLayout(W, H, GraphR, MetricsR, PlotR, cx, cy, r, Wide);
|
||
|
||
ok := (FCtl <> nil) and FCtl.GetBeaconScope(S);
|
||
|
||
// --- Точки констелляции ---
|
||
if ok and S.Enabled then
|
||
begin
|
||
C.Pen.Style := psClear;
|
||
C.Brush.Style := bsSolid;
|
||
C.Brush.Color := FTheme.MeterOn;
|
||
for i := 0 to S.Count - 1 do
|
||
begin
|
||
px := cx + Round(S.PtI[i] * (r / 2));
|
||
py := cy - Round(S.PtQ[i] * (r / 2));
|
||
if (px >= cx - r) and (px <= cx + r) and (py >= cy - r) and (py <= cy + r) then
|
||
C.FillRect(px - DpiScale(1), py - DpiScale(1),
|
||
px + DpiScale(2), py + DpiScale(2));
|
||
end;
|
||
end;
|
||
|
||
// --- Значения метрик ---
|
||
for n := 0 to 7 do Colors[n] := FTheme.Text;
|
||
if not ok then
|
||
begin
|
||
Values[0] := 'UNAVAILABLE'; Values[1] := 'UNAVAILABLE';
|
||
Values[2] := '--'; Values[3] := '--';
|
||
Values[4] := '--'; Values[5] := '--';
|
||
Values[6] := '--'; Values[7] := '--';
|
||
TechText := 'Decoder data unavailable';
|
||
Colors[0] := FTheme.TextDim;
|
||
Colors[1] := FTheme.TextDim;
|
||
end
|
||
else if not S.Enabled then
|
||
begin
|
||
Values[0] := 'OFF'; Values[1] := 'OFF';
|
||
Values[2] := '--'; Values[3] := '--';
|
||
Values[4] := '--'; Values[5] := '--';
|
||
Values[6] := IntToStr(S.Frames);
|
||
Values[7] := '--';
|
||
TechText := 'Enable BEACON and click the beacon in the tuning window';
|
||
Colors[0] := FTheme.TextDim;
|
||
Colors[1] := FTheme.TextDim;
|
||
end
|
||
else
|
||
begin
|
||
Values[0] := IfThen(S.CarrierLock, 'LOCKED', 'SEARCH');
|
||
Values[1] := IfThen(S.SymbolLock, 'LOCKED', 'SEARCH');
|
||
if S.SNRdB > -50 then Values[2] := Format('%.1f dB', [S.SNRdB])
|
||
else Values[2] := '--';
|
||
Values[3] := Format('%.1f Bd', [S.SymRate]);
|
||
Values[4] := Format('%.0f Hz', [S.OffsetHz]);
|
||
Values[5] := Format('%.0f Hz', [S.ResidHz]);
|
||
Values[6] := IntToStr(S.Frames);
|
||
Values[7] := Format('RS %d · P%d', [S.LastRSErr, S.ManPhase]);
|
||
TechText := Format('INV %s · PROM %.0f · Fs %.0f · Fd %.0f · SPS %.1f',
|
||
[IfThen(FCtl.BeaconDecodeInvert, 'ON', 'OFF'), S.CarProm,
|
||
S.FsHz, S.FdecHz, S.Sps]);
|
||
if S.CarrierLock then Colors[0] := FTheme.MeterOn
|
||
else Colors[0] := FTheme.TextDim;
|
||
if S.SymbolLock then Colors[1] := FTheme.MeterOn
|
||
else Colors[1] := FTheme.TextDim;
|
||
if S.HasFrame then
|
||
begin
|
||
Colors[6] := FTheme.MeterOn;
|
||
Colors[7] := FTheme.MeterOn;
|
||
end;
|
||
end;
|
||
|
||
C.Brush.Style := bsClear;
|
||
SetUIFont(9, [fsBold]);
|
||
for n := 0 to 7 do
|
||
begin
|
||
TileR := MetricTileRect(MetricsR, Wide, n);
|
||
C.Font.Color := Colors[n];
|
||
C.TextOut(TileR.Left + DpiScale(9), TileR.Top + DpiScale(21), Values[n]);
|
||
end;
|
||
SetUIFont(7);
|
||
C.Font.Color := FTheme.TextDim;
|
||
C.TextOut(MetricsR.Left + DpiScale(14),
|
||
MetricsR.Bottom - DpiScale(20), TechText);
|
||
|
||
FScopeValid := True;
|
||
end;
|
||
|
||
procedure TBeaconScopeForm.DoPaint(Sender: TObject);
|
||
begin
|
||
if (FBox.Width <= 0) or (FBox.Height <= 0) then Exit;
|
||
if (not FScopeValid) or (FScope.Width <> FBox.Width)
|
||
or (FScope.Height <> FBox.Height) then
|
||
DrawScope;
|
||
FBox.Canvas.Draw(0, 0, FScope);
|
||
end;
|
||
|
||
end.
|