Files
ewsdr/SpectrumView.pas
T
2026-05-21 15:38:56 +03:00

1733 lines
74 KiB
ObjectPascal

unit SpectrumView;
{
SpectrumView.pas — рендеринг спектра, водопада и S-метра.
TSpectrumView инкапсулирует всё рисование: спектр, водопад, линейка частот,
S-метр, индикаторы мощности/SWR. MainForm создаёт один экземпляр, передаёт
ссылки на TPaintBox после BuildUI, обновляет свойства при изменении состояния.
Зависимости: нет обратной зависимости на MainForm (нет circular dep).
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
uses
Classes, SysUtils, Graphics, ExtCtrls, Controls, Math,
IntfGraphics, FPImage, LCLIntf, LCLType, GraphType, AppTheme,
AlertOverlay;
const
SV_CLR_BG = TColor($00101010);
SV_CLR_BORDER = TColor($00303030);
SV_CLR_METER_ON = TColor($0000CC44);
SV_CLR_AMBER = TColor($0000AAFF);
type
TSpectrumView = class
private
// ── Off-screen bitmaps ────────────────────────────────────────────────────
FSpectrumBitmap: TBitmap;
FWaterfallBitmap: TBitmap; // размер-placeholder для DrawWaterfall
FWfBitmap: TBitmap;
FWfBitmapW: Integer;
FWfBitmapH: Integer;
FGridBitmap: TBitmap;
FGridBitmapW: Integer;
FGridBitmapH: Integer;
FFMGridLastCenter: Double;
FFMGridLastStepHz: Double;
FFMGridLastSpan: Double;
FRulerBitmap: TBitmap;
FSpecGradImg: TLazIntfImage;
FSpecGradBmp: TBitmap;
FWfIntfImg: TLazIntfImage;
FWfPixels: array of LongWord;
FSpPts: array of TPoint;
// ── Тема ─────────────────────────────────────────────────────────────────
FTheme: TAppTheme;
FLightTheme: Boolean;
FSpPtsLen: Integer;
FSmBitmap: TBitmap;
FRulerLastFreq: Double;
FRulerLastVfo: Double;
FRulerLastSpan: Double;
// ── Radio state ───────────────────────────────────────────────────────────
FVfoA: Double;
FVfoB: Double;
FActiveVfo: Integer;
FCenterFreq: Double;
FSpanHz: Double;
FMode: Integer;
FFilterBW: Integer;
FAGCTop: Integer;
FAGCThresh: Double;
FAGCHangLevel: Double;
FWDSPReady: Boolean;
// ── Waterfall settings ────────────────────────────────────────────────────
FWfAGCEnabled: Boolean;
FWfNFEnabled: Boolean;
FWfManualHigh: Double;
FWfManualLow: Double;
FWfAGCOffset: Double;
FWfHigh: Double;
FWfLow: Double;
// ── Display settings ──────────────────────────────────────────────────────
FSpecRefLevel: Double;
FSpecRange: Double;
FSpecGridStep: Double;
FFMGridStepHz: Double;
// ── TX overlay (Thetis-style: при TX данные в FSpectrumBuf приходят
// от TX-анализатора @ FTXSpanHz Гц вокруг FTXFreq, и должны быть
// отрисованы в RX-координатах (FCenterFreq/FSpanHz), чтобы линейка
// частот совпадала с RX и сигнал стоял на правильной частоте даже
// при включённом CTUN. Если RX rate ≠ TX rate, видна только пересечение.
FTXMode: Boolean;
FTXFreq: Double; // центр TX-сигнала (Hz)
FTXSpanHz: Double; // ширина TX-окна (192000 для Hermes/Saturn)
FTXVfoIndex: Integer; // 0=A, 1=B — какой VFO привязан к TX (для split)
// FTXOverlay — независимый от FTXMode флаг: «нарисовать TX-фильтр поверх
// данных». DUP-режим: FTXMode=False (данные RX), но FTXOverlay=True (TX-полоса
// красным сверху). Без DUP при TX оба флага True. Без TX оба False.
FTXOverlay: Boolean;
// ── ADC overload overlay ─────────────────────────────────────────────────
FADCOverloadVisible: Boolean;
// ── Marker ────────────────────────────────────────────────────────────────
FMarkerActive: Boolean;
FMarkerX: Integer;
// ── Meter values ──────────────────────────────────────────────────────────
FLastSMeter: Double;
FSMeterPeak: Double;
FSMeterMin: Double;
FLastFwdW: Double;
FLastSWR: Double;
FPAMaxPower: Double;
FTransmitting: Boolean;
// ── Буферы данных (пишутся из DSP-потока) ────────────────────────────────
FSpectrumBuf: array[0..1023] of Single;
FWaterfallBuf: array[0..1023] of Single;
FSpectrumBufCount: Integer;
FWaterfallBufCount: Integer;
FSpectrumDirty: Boolean;
FWaterfallDirty: Boolean;
FWfFrameCounter: Integer;
FWfFrameInterval: Integer;
// ── Ссылки на TPaintBox (устанавливаются из MainForm после BuildUI) ───────
FPbSpectrum: TPaintBox;
FPbWaterfall: TPaintBox;
FPbRuler: TPaintBox;
FPbSMeterRight: TPaintBox;
FPbFwdPower: TPaintBox;
FPbSWR: TPaintBox;
// ── Приватные методы рендеринга ───────────────────────────────────────────
function ActiveVfoFreq: Double;
function ScaleX(X, Total, Width: Integer): Integer;
procedure SetFMGridStepHz(V: Double);
procedure DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer);
procedure DrawMarkerLine(C: TCanvas; W, H: Integer);
// Альфа-blending вертикальной полосы поверх FSpectrumBitmap (pf32bit, BGRA).
// Используется для прозрачной заливки полосы фильтра разными цветами
// (RX-зелёный / TX-красный) поверх градиента сетки.
procedure BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte);
procedure DrawADCOverloadOverlay(C: TCanvas; W, H: Integer);
// Считает X1/X2 для полосы фильтра вокруг указанной частоты VFO, c учётом
// текущего режима (LSB/USB/DSB и пр.) и FFilterBW.
procedure CalcFilterBandX(VfoFreq: Double; W: Integer;
out X1, X2, VfoX: Integer);
public
constructor Create;
destructor Destroy; override;
// ── Состояние радио ───────────────────────────────────────────────────────
property VfoA: Double read FVfoA write FVfoA;
property VfoB: Double read FVfoB write FVfoB;
property ActiveVfo: Integer read FActiveVfo write FActiveVfo;
property CenterFreq: Double read FCenterFreq write FCenterFreq;
property SpanHz: Double read FSpanHz write FSpanHz;
property Mode: Integer read FMode write FMode;
property FilterBW: Integer read FFilterBW write FFilterBW;
property AGCTop: Integer read FAGCTop write FAGCTop;
property AGCThresh: Double write FAGCThresh;
property AGCHangLevel: Double write FAGCHangLevel;
property WDSPReady: Boolean read FWDSPReady write FWDSPReady;
// ── Водопад ───────────────────────────────────────────────────────────────
property WfAGCEnabled: Boolean read FWfAGCEnabled write FWfAGCEnabled;
property WfNFEnabled: Boolean read FWfNFEnabled write FWfNFEnabled;
property WfManualHigh: Double read FWfManualHigh write FWfManualHigh;
property WfManualLow: Double read FWfManualLow write FWfManualLow;
property WfAGCOffset: Double read FWfAGCOffset write FWfAGCOffset;
// ── Отображение ───────────────────────────────────────────────────────────
property SpecRefLevel: Double read FSpecRefLevel write FSpecRefLevel;
property SpecRange: Double read FSpecRange write FSpecRange;
property SpecGridStep: Double read FSpecGridStep write FSpecGridStep;
property FMGridStepHz: Double read FFMGridStepHz write SetFMGridStepHz;
// ── TX overlay ────────────────────────────────────────────────────────────
property TXMode: Boolean read FTXMode write FTXMode;
property TXFreq: Double read FTXFreq write FTXFreq;
property TXSpanHz: Double read FTXSpanHz write FTXSpanHz;
property TXVfoIndex: Integer read FTXVfoIndex write FTXVfoIndex;
property TXOverlay: Boolean read FTXOverlay write FTXOverlay;
// Test overlay for ADC overload; later this will be driven from HP Status.
property ADCOverloadVisible: Boolean read FADCOverloadVisible write FADCOverloadVisible;
// ── Маркер ────────────────────────────────────────────────────────────────
property MarkerActive: Boolean read FMarkerActive write FMarkerActive;
property MarkerX: Integer read FMarkerX write FMarkerX;
// ── S-метр ────────────────────────────────────────────────────────────────
property LastSMeter: Double read FLastSMeter write FLastSMeter;
property SMeterPeak: Double read FSMeterPeak write FSMeterPeak;
property SMeterMin: Double read FSMeterMin write FSMeterMin;
property LastFwdW: Double read FLastFwdW write FLastFwdW;
property LastSWR: Double read FLastSWR write FLastSWR;
property PAMaxPower: Double read FPAMaxPower write FPAMaxPower;
property Transmitting: Boolean read FTransmitting write FTransmitting;
// ── Флаги обновления ──────────────────────────────────────────────────────
property SpectrumDirty: Boolean read FSpectrumDirty write FSpectrumDirty;
property WaterfallDirty: Boolean read FWaterfallDirty write FWaterfallDirty;
property WfFrameInterval: Integer read FWfFrameInterval write FWfFrameInterval;
// ── Ссылки на PaintBox ────────────────────────────────────────────────────
property PbSpectrum: TPaintBox write FPbSpectrum;
property PbWaterfall: TPaintBox write FPbWaterfall;
property PbRuler: TPaintBox write FPbRuler;
property PbSMeterRight: TPaintBox write FPbSMeterRight;
property PbFwdPower: TPaintBox write FPbFwdPower;
property PbSWR: TPaintBox write FPbSWR;
// ── Данные от DSP (вызываются из DSP-потока) ──────────────────────────────
procedure SetSpectrumData(const Pixels: array of Single; Count: Integer);
procedure SetWaterfallData(const Pixels: array of Single; Count: Integer);
// ── Рендеринг ─────────────────────────────────────────────────────────────
procedure DrawSpectrum;
procedure DrawWaterfall;
procedure DrawRuler;
procedure DrawBarMeter(ACanvas: TCanvas; R: TRect;
Value, MaxVal: Double; BarColor: TColor);
procedure DrawSMeterWide(ACanvas: TCanvas; R: TRect;
Value, Peak, MinVal: Double;
out ZX1, ZX2, ZY1, ZY2: Integer);
procedure DrawSMeterZone(ACanvas: TCanvas; R: TRect;
X1, X2, Y1, Y2: Integer);
procedure DrawTXMeter(ACanvas: TCanvas; R: TRect);
// ── Управление размером bitmap ────────────────────────────────────────────
procedure SetSpectrumBitmapSize(W, H: Integer);
procedure SetWaterfallBitmapSize(W, H: Integer);
procedure SetRulerSize(W, H: Integer);
function SpectrumBitmapWidth: Integer;
// ── Paint-обработчики (назначаются в BuildUI) ─────────────────────────────
procedure PaintSpectrum(Sender: TObject);
procedure PaintWaterfall(Sender: TObject);
procedure PaintRuler(Sender: TObject);
procedure PaintSMeterRight(Sender: TObject);
procedure PaintFwdPower(Sender: TObject);
procedure PaintSWR(Sender: TObject);
// ── Утилиты ───────────────────────────────────────────────────────────────
procedure InvalidateGridCache;
// ── Тема ─────────────────────────────────────────────────────────────────
procedure SetTheme(const T: TAppTheme);
procedure InvalidateRulerCache;
procedure ResetSpectrumBuf;
procedure ResetWfAvgBuf;
procedure FillDemoSpectrum;
function NeedsRulerRedraw: Boolean;
end;
// Вспомогательные функции (публичные для возможного использования в MainForm)
function FormatFreqSV(Hz: Double): string;
implementation
// ════════════════════════════════════════════════════════════════════════════
// Вспомогательные функции — цвет, градиент
// ════════════════════════════════════════════════════════════════════════════
function WaterfallColorThetis(Level: Integer): LongWord;
const
STOPS: array[0..5] of LongWord = (
$000000, $0A1A48, $0088CC, $E0C020, $D84A12, $FFFFFF);
var
Seg, Base: Integer;
T: Double;
C0, C1: LongWord;
R, G, B: Integer;
begin
Level := EnsureRange(Level, 0, 255);
Seg := Min(4, Level div 51);
Base := Seg * 51;
T := (Level - Base) / 51.0;
C0 := STOPS[Seg];
C1 := STOPS[Seg + 1];
R := Round(((C0 shr 16) and $FF) * (1.0 - T) + ((C1 shr 16) and $FF) * T);
G := Round(((C0 shr 8) and $FF) * (1.0 - T) + ((C1 shr 8) and $FF) * T);
B := Round((C0 and $FF) * (1.0 - T) + (C1 and $FF) * T);
Result := ($FF shl 24) or (R shl 16) or (G shl 8) or B;
end;
function WaterfallEnhancedColorThetis(ValueDB, LowDB, HighDB: Double): LongWord;
var
Overall, Local: Double;
R, G, B: Integer;
begin
if ValueDB <= LowDB then begin Result := $FF000000; Exit; end;
if ValueDB >= HighDB then begin Result := ($FF shl 24) or (255 shl 16) or (124 shl 8) or 192; Exit; end;
Overall := (ValueDB - LowDB) / Max(1E-9, HighDB - LowDB);
if Overall < (2.0 / 9.0) then
begin Local := Overall / (2.0/9.0); R := 0; G := 0; B := Round(Local * 255.0); end
else if Overall < (3.0 / 9.0) then
begin Local := (Overall - 2.0/9.0) / (1.0/9.0); R := 0; G := Round(Local*255.0); B := 255; end
else if Overall < (4.0 / 9.0) then
begin Local := (Overall - 3.0/9.0) / (1.0/9.0); R := 0; G := 255; B := Round((1.0-Local)*255.0); end
else if Overall < (5.0 / 9.0) then
begin Local := (Overall - 4.0/9.0) / (1.0/9.0); R := Round(Local*255.0); G := 255; B := 0; end
else if Overall < (7.0 / 9.0) then
begin Local := (Overall - 5.0/9.0) / (2.0/9.0); R := 255; G := Round((1.0-Local)*255.0); B := 0; end
else if Overall < (8.0 / 9.0) then
begin Local := (Overall - 7.0/9.0) / (1.0/9.0); R := 255; G := 0; B := Round(Local*255.0); end
else
begin
Local := (Overall - 8.0/9.0) / (1.0/9.0);
R := Round((0.75 + 0.25 * (1.0 - Local)) * 255.0);
G := Round(Local * 255.0 * 0.5);
B := 255;
end;
R := EnsureRange(R, 0, 255);
G := EnsureRange(G, 0, 255);
B := EnsureRange(B, 0, 255);
Result := ($FF shl 24) or (R shl 16) or (G shl 8) or B;
end;
// Цветовая схема водопада для светлой темы.
// Шум растворяется в фоне панели; сигналы: синий → cyan → зелёный → жёлтый → оранжевый → красный.
function WaterfallLightTheme(ValueDB, LowDB, HighDB: Double): LongWord;
const
NSTOPS = 8;
// panel steel blue cyan green yell orange red
SR: array[0..NSTOPS-1] of Integer = (224, 160, 20, 0, 0, 220, 255, 255);
SG: array[0..NSTOPS-1] of Integer = (230, 185, 80, 185, 200, 210, 90, 20);
SB: array[0..NSTOPS-1] of Integer = (232, 210, 200, 210, 80, 0, 0, 20);
var
T: Double;
Seg: Integer;
R, G, B: Integer;
begin
if ValueDB <= LowDB then begin Result := $FFE0E6E8; Exit; end;
if ValueDB >= HighDB then begin Result := ($FF shl 24) or (SR[NSTOPS-1] shl 16) or (SG[NSTOPS-1] shl 8) or SB[NSTOPS-1]; Exit; end;
T := (ValueDB - LowDB) / Max(1E-9, HighDB - LowDB) * (NSTOPS - 1);
Seg := Min(NSTOPS - 2, Trunc(T));
T := T - Seg;
R := EnsureRange(Round(SR[Seg] * (1.0 - T) + SR[Seg+1] * T), 0, 255);
G := EnsureRange(Round(SG[Seg] * (1.0 - T) + SG[Seg+1] * T), 0, 255);
B := EnsureRange(Round(SB[Seg] * (1.0 - T) + SB[Seg+1] * T), 0, 255);
Result := ($FF shl 24) or (R shl 16) or (G shl 8) or B;
end;
procedure InitRawDesc32(var Desc: TRawImageDescription; AWidth, AHeight: Integer);
begin
FillChar(Desc, SizeOf(Desc), 0);
Desc.Format := ricfRGBA;
Desc.Width := AWidth;
Desc.Height := AHeight;
Desc.Depth := 32;
Desc.BitOrder := riboBitsInOrder;
Desc.ByteOrder := riboLSBFirst;
Desc.LineOrder := riloTopToBottom;
Desc.BitsPerPixel := 32;
Desc.LineEnd := rileDWordBoundary;
Desc.BlueShift := 0; Desc.BluePrec := 8;
Desc.GreenShift := 8; Desc.GreenPrec := 8;
Desc.RedShift := 16; Desc.RedPrec := 8;
Desc.AlphaShift := 24; Desc.AlphaPrec := 8;
end;
function MixColorBGR(C1, C2: TColor; T: Double): TColor;
var
B1, G1, R1, B2, G2, R2, B, G, R: Integer;
begin
if T < 0.0 then T := 0.0 else if T > 1.0 then T := 1.0;
B1 := (C1 shr 16) and $FF; G1 := (C1 shr 8) and $FF; R1 := C1 and $FF;
B2 := (C2 shr 16) and $FF; G2 := (C2 shr 8) and $FF; R2 := C2 and $FF;
B := Round(B1 + (B2-B1)*T); G := Round(G1 + (G2-G1)*T); R := Round(R1 + (R2-R1)*T);
Result := TColor((B shl 16) or (G shl 8) or R);
end;
procedure PaintVerticalGradient(C: TCanvas; W, H: Integer; TopColor, BottomColor: TColor);
var Y: Integer; T: Double;
begin
if (W <= 0) or (H <= 0) then Exit;
C.Pen.Style := psClear; C.Brush.Style := bsSolid;
for Y := 0 to H - 1 do
begin
T := Y / Max(1, H - 1);
C.Brush.Color := MixColorBGR(TopColor, BottomColor, T);
C.FillRect(Rect(0, Y, W, Y + 1));
end;
C.Pen.Style := psSolid;
end;
function FormatFreqSV(Hz: Double): string;
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;
// ════════════════════════════════════════════════════════════════════════════
// TSpectrumView
// ════════════════════════════════════════════════════════════════════════════
constructor TSpectrumView.Create;
begin
inherited Create;
FSpectrumBitmap := TBitmap.Create;
// pf32bit нужен для попиксельного alpha-blend в BlendBand (TX/RX-полосы фильтра).
FSpectrumBitmap.PixelFormat := pf32bit;
FWaterfallBitmap := TBitmap.Create;
FWfBitmap := TBitmap.Create;
FGridBitmap := TBitmap.Create;
FRulerBitmap := TBitmap.Create;
FSpecGradBmp := TBitmap.Create;
FSmBitmap := nil;
FSpecGradImg := nil;
FWfIntfImg := nil;
FWfBitmapW := 0; FWfBitmapH := 0;
FGridBitmapW := 0; FGridBitmapH := 0;
FFMGridLastCenter := -1.0; FFMGridLastStepHz := -1.0; FFMGridLastSpan := -1.0;
FFMGridStepHz := 0.0;
FSpPtsLen := 0;
FRulerLastFreq := -1.0; FRulerLastVfo := -1.0;
// Водопад AGC defaults
FWfManualHigh := -80.0;
FWfManualLow := -130.0;
FWfAGCOffset := 0.0;
FWfHigh := -80.0;
FWfLow := -130.0;
FWfAGCEnabled := True;
FWfNFEnabled := False;
// Spectrum display defaults
FSpecRefLevel := -20.0;
FSpecRange := 110.0;
FSpecGridStep := 10.0;
FSpanHz := 192000;
// TX overlay defaults — сетка совпадает с RX по умолчанию
FTXMode := False;
FTXFreq := 0.0;
FTXSpanHz := 192000.0;
FTXVfoIndex := 0;
FTXOverlay := False;
FADCOverloadVisible := True; // test indicator; later driven by HP Status.
FSpectrumBufCount := 1024;
FWaterfallBufCount := 1024;
FWfFrameInterval := 2;
FWfFrameCounter := 0;
FWaterfallDirty := True;
FLastSMeter := -130; FSMeterPeak := -130; FSMeterMin := -130;
FLastFwdW := 0; FLastSWR := 1;
FPAMaxPower := 100.0; FTransmitting := False;
FLightTheme := False;
FTheme := DarkTheme;
ResetSpectrumBuf;
end;
destructor TSpectrumView.Destroy;
begin
FSpectrumBitmap.Free;
FWaterfallBitmap.Free;
FWfBitmap.Free;
FGridBitmap.Free;
FRulerBitmap.Free;
FSpecGradBmp.Free;
FreeAndNil(FSpecGradImg);
FreeAndNil(FWfIntfImg);
FreeAndNil(FSmBitmap);
inherited;
end;
// ────────────────────────────────────────────────────────────────────────────
// Приватные вспомогательные методы
// ────────────────────────────────────────────────────────────────────────────
function TSpectrumView.ActiveVfoFreq: Double;
begin
if FActiveVfo = 0 then Result := FVfoA else Result := FVfoB;
end;
function TSpectrumView.ScaleX(X, Total, Width: Integer): Integer;
begin
if Total = 0 then Result := 0
else Result := Round(X / Total * Width);
end;
procedure TSpectrumView.SetFMGridStepHz(V: Double);
begin
if Abs(FFMGridStepHz - V) > 0.5 then
begin
FFMGridStepHz := V;
InvalidateGridCache;
InvalidateRulerCache;
end;
end;
procedure TSpectrumView.DrawMarkerLine(C: TCanvas; W, H: Integer);
var MX: Integer; MarkerFreq: Double; MarkerLbl: string;
begin
MX := Round(FMarkerX / 1000.0 * W);
if (MX < 0) or (MX >= W) then Exit;
MarkerFreq := (FCenterFreq - FSpanHz / 2) + FMarkerX / 1000.0 * FSpanHz;
MarkerLbl := FormatFreqSV(Round(MarkerFreq));
C.Pen.Color := TColor($004444FF);
C.Pen.Width := 1; C.Pen.Style := psSolid;
C.MoveTo(MX, 0); C.LineTo(MX, H);
C.Font.Color := TColor($004444FF);
C.Font.Size := 7; C.Font.Name := 'Courier New';
if MX + 4 + C.TextWidth(MarkerLbl) < W then
C.TextOut(MX + 4, 4, MarkerLbl)
else
C.TextOut(MX - 4 - C.TextWidth(MarkerLbl), 4, MarkerLbl);
end;
procedure TSpectrumView.BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte);
// Накладывает полупрозрачную вертикальную полосу [X1..X2) высотой H на
// FSpectrumBitmap. Канал A — сила оверлея 0..255 (255 = полностью непрозрачно).
// Формула: dst = src*(1-a) + color*a, BGRA-порядок (pf32bit Lazarus default).
// BeginUpdate(False) — нужен для прямого доступа к raw-пикселям через ScanLine.
var
Y, X, InvA: Integer;
Row: PByte;
W: Integer;
begin
if (FSpectrumBitmap = nil) or (Alpha = 0) then Exit;
W := FSpectrumBitmap.Width;
if X1 < 0 then X1 := 0;
if X2 > W then X2 := W;
if X2 <= X1 then Exit;
InvA := 255 - Alpha;
FSpectrumBitmap.BeginUpdate(False);
try
for Y := 0 to H - 1 do
begin
Row := PByte(FSpectrumBitmap.ScanLine[Y]);
if Row = nil then Continue;
Inc(Row, X1 * 4);
for X := X1 to X2 - 1 do
begin
Row[0] := Byte((Alpha * B + InvA * Row[0]) div 255);
Row[1] := Byte((Alpha * G + InvA * Row[1]) div 255);
Row[2] := Byte((Alpha * R + InvA * Row[2]) div 255);
// Row[3] оставляем — на BitBlt в TPaintBox не влияет.
Inc(Row, 4);
end;
end;
finally
FSpectrumBitmap.EndUpdate(False);
end;
end;
procedure TSpectrumView.DrawADCOverloadOverlay(C: TCanvas; W, H: Integer);
begin
if not FADCOverloadVisible then Exit;
DrawAlertOverlay(FSpectrumBitmap, C, W, H, 'ADC OVERLOAD', 'Input clipping detected');
end;
procedure TSpectrumView.CalcFilterBandX(VfoFreq: Double; W: Integer;
out X1, X2, VfoX: Integer);
// Возвращает X-координаты левого/правого края полосы фильтра вокруг VfoFreq
// и саму позицию VFO. Учитывает FMode (LSB/USB/DSB) и FFilterBW.
var
Lo_Hz, Hi_Hz, Half: Double;
begin
Half := FFilterBW / 2;
VfoX := Round((VfoFreq - FCenterFreq + FSpanHz / 2) / FSpanHz * W);
case FMode of
0: begin Lo_Hz := -FFilterBW; Hi_Hz := -100; end;
1: begin Lo_Hz := 100; Hi_Hz := FFilterBW; end;
else begin Lo_Hz := -Half; Hi_Hz := Half; end;
end;
X1 := Max(0, Min(W - 1, VfoX + Round(Lo_Hz / FSpanHz * W)));
X2 := Max(0, Min(W - 1, VfoX + Round(Hi_Hz / FSpanHz * W)));
end;
procedure TSpectrumView.DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer);
var
Row, Col: Integer; YMin, Alpha: Integer;
RowPtr: PByte; BPPg: Integer; RG, GG: Byte;
Desc: TRawImageDescription;
begin
if FSpectrumBitmap = nil then Exit;
if (W <= 0) or (H <= 0) then Exit;
YMin := H;
for Col := 0 to W - 1 do
if SpPts[Col].Y < YMin then YMin := SpPts[Col].Y;
if YMin >= H then Exit;
if (FSpecGradImg = nil) or (FSpecGradImg.Width <> W) or (FSpecGradImg.Height <> H) then
begin
FreeAndNil(FSpecGradImg);
FSpecGradImg := TLazIntfImage.Create(W, H);
InitRawDesc32(Desc, W, H);
FSpecGradImg.DataDescription := Desc;
FSpecGradImg.CreateData;
end;
if FSpecGradImg.PixelData <> nil then
FillChar(FSpecGradImg.PixelData^, W * H * 4, 0);
BPPg := FSpecGradImg.DataDescription.BitsPerPixel div 8;
if BPPg < 3 then BPPg := 4;
for Row := YMin to H - 1 do
begin
Alpha := Round((1.0 - Sqr((Row - YMin) / Max(1.0, H - YMin - 1.0))) * 200);
if Alpha <= 0 then Continue;
if Alpha > 200 then Alpha := 200;
GG := Byte(FTheme.SpecGradB * Alpha div 200);
RG := Byte(FTheme.SpecGradG * Alpha div 200);
RowPtr := FSpecGradImg.GetDataLineStart(Row);
if RowPtr = nil then Continue;
for Col := 0 to W - 1 do
begin
if SpPts[Col].Y <= Row then
begin
RowPtr[0] := GG;
RowPtr[1] := RG;
RowPtr[2] := Byte(FTheme.SpecGradR * Alpha div 200);
if BPPg >= 4 then RowPtr[3] := Byte(Alpha);
end else begin
RowPtr[0] := 0; RowPtr[1] := 0; RowPtr[2] := 0;
if BPPg >= 4 then RowPtr[3] := 0;
end;
Inc(RowPtr, BPPg);
end;
end;
FSpecGradBmp.LoadFromIntfImage(FSpecGradImg);
LCLIntf.BitBlt(FSpectrumBitmap.Canvas.Handle, 0, 0, W, H,
FSpecGradBmp.Canvas.Handle, 0, 0, $CC0020);
end;
// ────────────────────────────────────────────────────────────────────────────
// Публичные методы данных
// ────────────────────────────────────────────────────────────────────────────
procedure TSpectrumView.SetSpectrumData(const Pixels: array of Single; Count: Integer);
var i, N: Integer;
begin
N := Min(Count, 1024);
for i := 0 to N - 1 do FSpectrumBuf[i] := Pixels[i];
FSpectrumBufCount := N;
FSpectrumDirty := True;
end;
procedure TSpectrumView.SetWaterfallData(const Pixels: array of Single; Count: Integer);
var i, N: Integer;
begin
N := Min(Count, 1024);
for i := 0 to N - 1 do FWaterfallBuf[i] := Pixels[i];
FWaterfallBufCount := N;
Inc(FWfFrameCounter);
if FWfFrameCounter >= Max(1, FWfFrameInterval) then
begin
FWfFrameCounter := 0;
FWaterfallDirty := True;
end;
end;
// ────────────────────────────────────────────────────────────────────────────
// Размеры bitmap
// ────────────────────────────────────────────────────────────────────────────
procedure TSpectrumView.SetSpectrumBitmapSize(W, H: Integer);
begin
if (W <= 0) or (H <= 0) then Exit;
FSpectrumBitmap.SetSize(W, H);
FSpectrumBitmap.Canvas.Brush.Color := FTheme.BG;
FSpectrumBitmap.Canvas.FillRect(Rect(0, 0, W, H));
FreeAndNil(FSpecGradImg);
InvalidateGridCache;
FSpPtsLen := 0;
end;
procedure TSpectrumView.SetWaterfallBitmapSize(W, H: Integer);
begin
FWaterfallBitmap.SetSize(W, H);
FWfBitmapW := 0; FWfBitmapH := 0;
SetLength(FWfPixels, 0);
FreeAndNil(FWfIntfImg);
end;
procedure TSpectrumView.SetRulerSize(W, H: Integer);
begin
if (W <= 0) or (H <= 0) then Exit;
FRulerBitmap.SetSize(W, H);
FRulerLastFreq := -1.0; FRulerLastVfo := -1.0; FRulerLastSpan := -1.0;
end;
function TSpectrumView.SpectrumBitmapWidth: Integer;
begin
Result := FSpectrumBitmap.Width;
end;
// ────────────────────────────────────────────────────────────────────────────
// Утилиты
// ────────────────────────────────────────────────────────────────────────────
procedure TSpectrumView.InvalidateGridCache;
begin
FGridBitmapW := 0; FGridBitmapH := 0;
FSpectrumDirty := True;
end;
procedure TSpectrumView.SetTheme(const T: TAppTheme);
begin
FTheme := T;
FLightTheme := T.BG > TColor($00808080); // светлая тема если фон светлее 50%
FreeAndNil(FSpecGradImg); // пересчитать градиент
InvalidateGridCache;
InvalidateRulerCache;
FWaterfallDirty := True;
end;
procedure TSpectrumView.InvalidateRulerCache;
begin
FRulerLastFreq := -1.0; FRulerLastVfo := -1.0; FRulerLastSpan := -1.0;
end;
procedure TSpectrumView.ResetSpectrumBuf;
var i: Integer;
begin
for i := 0 to 1023 do FSpectrumBuf[i] := -130.0;
for i := 0 to 1023 do FWaterfallBuf[i] := -130.0;
FWfHigh := FWfManualHigh; FWfLow := FWfManualLow;
FWaterfallDirty := True;
end;
procedure TSpectrumView.ResetWfAvgBuf;
begin
FWfHigh := FWfManualHigh; FWfLow := FWfManualLow;
FWaterfallDirty := True;
end;
procedure TSpectrumView.FillDemoSpectrum;
var i: Integer; FreqOff, Noise, Sig: Double; W: Integer;
begin
W := FSpectrumBitmap.Width;
if W <= 0 then W := 1024;
for i := 0 to W - 1 do
begin
if W > 1 then FreqOff := (i / (W - 1) - 0.5) * FSpanHz
else FreqOff := 0;
Noise := -110 + (Random - 0.5) * 6;
if Abs(FreqOff) < 2000 then Sig := -50 - Abs(FreqOff) / 200
else Sig := -999;
FSpectrumBuf[i mod 1024] := Max(Noise, Sig);
end;
end;
function TSpectrumView.NeedsRulerRedraw: Boolean;
begin
Result := (Abs(FCenterFreq - FRulerLastFreq) >= 0.5) or
(Abs(ActiveVfoFreq - FRulerLastVfo) >= 0.5) or
(Abs(FSpanHz - FRulerLastSpan) >= 1.0);
end;
// ────────────────────────────────────────────────────────────────────────────
// DrawSpectrum
// ────────────────────────────────────────────────────────────────────────────
procedure TSpectrumView.DrawSpectrum;
var
C, GC: TCanvas;
i, Yp, W, H, GX: Integer;
DBmin, DBmax, dB: Double;
VfoX, X1, X2: Integer;
TXVfoX, TXX1, TXX2: Integer;
AGCy, AGCHangY: Integer;
SrcF, Frac, dBv: Double;
S0, S1: Integer;
InvRange: Double;
LabelBandW, SrcCount: Integer;
GridFreqS, GridLine, pixPerStep: Double;
N, gridMult: Integer;
begin
if FSpectrumBitmap = nil then Exit;
W := FSpectrumBitmap.Width; H := FSpectrumBitmap.Height;
if (W <= 0) or (H <= 0) then Exit;
C := FSpectrumBitmap.Canvas;
DBmax := FSpecRefLevel;
DBmin := FSpecRefLevel - FSpecRange;
InvRange := 1.0 / (DBmax - DBmin);
// ── 1. Фон+сетка из кэша ──────────────────────────────────────────────────
if (FGridBitmapW <> W) or (FGridBitmapH <> H) or
((FFMGridStepHz > 0) and
((Abs(FCenterFreq - FFMGridLastCenter) > 0.5) or
(FFMGridStepHz <> FFMGridLastStepHz) or
(Abs(FSpanHz - FFMGridLastSpan) > 1.0))) then
begin
FGridBitmapW := W; FGridBitmapH := H;
FFMGridLastCenter := FCenterFreq;
FFMGridLastStepHz := FFMGridStepHz;
FFMGridLastSpan := FSpanHz;
FGridBitmap.SetSize(W, H);
GC := FGridBitmap.Canvas;
PaintVerticalGradient(GC, W, H, FTheme.SpecGradTop, FTheme.SpecGradBot);
LabelBandW := 28;
GC.Pen.Style := psClear; GC.Brush.Style := bsSolid;
GC.Brush.Color := FTheme.SpecLabelBand;
GC.FillRect(Rect(0, 0, LabelBandW, H));
GC.Pen.Style := psSolid;
GC.Font.Size := 7; GC.Font.Name := 'Courier New';
GC.Font.Color := FTheme.SpecLabelText;
GC.Brush.Style := bsClear;
dB := DBmax - FSpecGridStep;
while dB >= DBmin do
begin
Yp := Round((DBmax - dB) * InvRange * H);
GC.Pen.Color := FTheme.SpecGrid; GC.Pen.Width := 1;
GC.MoveTo(0, Yp); GC.LineTo(W-1, Yp);
GC.TextOut(1, Yp - 9, Format('%4.0f', [dB]));
dB := dB - FSpecGridStep;
end;
if FFMGridStepHz > 0 then
begin
GridFreqS := FCenterFreq - FSpanHz / 2;
pixPerStep := W * FFMGridStepHz / FSpanHz;
if pixPerStep >= 1.0 then
gridMult := Max(1, Ceil(4.0 / pixPerStep))
else
gridMult := 0;
if gridMult > 0 then
begin
N := Ceil(GridFreqS / FFMGridStepHz);
GridLine := N * FFMGridStepHz;
while GridLine <= FCenterFreq + FSpanHz / 2 + 0.5 do
begin
if (N mod gridMult) = 0 then
begin
GX := Round((GridLine - GridFreqS) / FSpanHz * W);
if (GX >= 0) and (GX < W) then
begin
GC.Pen.Color := FTheme.SpecGrid;
GC.MoveTo(GX, 0); GC.LineTo(GX, H);
end;
end;
Inc(N);
GridLine := GridLine + FFMGridStepHz;
end;
end;
end else
begin
for i := 0 to 8 do
begin
GX := ScaleX(i, 8, W);
GC.Pen.Color := FTheme.SpecGrid;
GC.MoveTo(GX, 0); GC.LineTo(GX, H);
end;
end;
end;
LCLIntf.BitBlt(C.Handle, 0, 0, W, H, FGridBitmap.Canvas.Handle, 0, 0, $CC0020);
// ── 2. Полоса фильтра ─────────────────────────────────────────────────────
// Логика:
// Не TX → одна полоса на FActiveVfo, штатным цветом FTheme.SpecFilter (solid).
// TX, не split → одна полоса на FActiveVfo, alpha-blend красный.
// TX, split → две полосы: RX (FActiveVfo) — alpha-blend зелёный,
// TX (FTXVfoIndex) — alpha-blend красный.
// Edges + VFO-cursor рисуются позже на VfoX/X1/X2 — это RX-VFO-полоса
// (для split-режима TX-полоса рисуется без edges чтобы визуально отличалась).
if FActiveVfo = 0 then CalcFilterBandX(FVfoA, W, X1, X2, VfoX)
else CalcFilterBandX(FVfoB, W, X1, X2, VfoX);
if FTXOverlay then
begin
if FTXVfoIndex <> FActiveVfo then
begin
// Split TX: RX-полоса зелёная, TX-полоса (на другом VFO) красная.
if X2 > X1 then BlendBand(X1, X2, H, $30, $C0, $30, 100);
if FTXVfoIndex = 0 then CalcFilterBandX(FVfoA, W, TXX1, TXX2, TXVfoX)
else CalcFilterBandX(FVfoB, W, TXX1, TXX2, TXVfoX);
if TXX2 > TXX1 then BlendBand(TXX1, TXX2, H, $E0, $30, $30, 100);
end else
// Не split: единственная полоса — TX на активном VFO, красная.
if X2 > X1 then BlendBand(X1, X2, H, $E0, $30, $30, 100);
end else
begin
// Обычный приём — заливка штатным цветом без alpha.
C.Brush.Color := FTheme.SpecFilter; C.Brush.Style := bsSolid; C.Pen.Style := psClear;
if X2 > X1 then C.FillRect(Rect(X1, 0, X2, H));
C.Pen.Style := psSolid;
end;
// ── 3. AGC линии ──────────────────────────────────────────────────────────
if FWDSPReady then
begin
AGCy := Round((DBmax - FAGCThresh) * InvRange * H);
if (AGCy >= 0) and (AGCy < H) then
begin
C.Pen.Color := FTheme.SpecAgcColor; C.Pen.Width := 1; C.Pen.Style := psDash;
C.MoveTo(0, AGCy); C.LineTo(W, AGCy);
C.Pen.Style := psSolid; C.Font.Color := FTheme.SpecAgcColor; C.Font.Size := 6;
C.TextOut(4, AGCy - 9, 'AGC T');
end;
AGCHangY := Round((DBmax - FAGCHangLevel) * InvRange * H);
if (AGCHangY >= 0) and (AGCHangY < H) and (Abs(AGCHangY - AGCy) > 4) then
begin
C.Pen.Color := FTheme.SpecAgcHangColor; C.Pen.Width := 1; C.Pen.Style := psDot;
C.MoveTo(0, AGCHangY); C.LineTo(W, AGCHangY);
C.Pen.Style := psSolid; C.Font.Color := FTheme.SpecAgcHangColor; C.Font.Size := 6;
C.TextOut(4, AGCHangY + 2, 'AGC H');
end;
end else begin
AGCy := Round((DBmax - (-FAGCTop)) * InvRange * H);
if (AGCy >= 0) and (AGCy < H) then
begin
C.Pen.Color := FTheme.SpecAgcColor; C.Pen.Width := 1; C.Pen.Style := psDash;
C.MoveTo(0, AGCy); C.LineTo(W, AGCy);
C.Pen.Style := psSolid; C.Font.Color := FTheme.SpecAgcColor; C.Font.Size := 6;
C.TextOut(4, AGCy - 9, Format('AGC -%ddB', [FAGCTop]));
end;
end;
// ── 4. Кривая спектра ─────────────────────────────────────────────────────
if FSpPtsLen <> W + 2 then
begin SetLength(FSpPts, W + 2); FSpPtsLen := W + 2; end;
SrcCount := EnsureRange(FSpectrumBufCount, 2, 1024);
if FTXMode and (FTXSpanHz > 0) and (FSpanHz > 0) then
begin
// TX-режим: данные приходят @ FTXSpanHz вокруг FTXFreq (TX-анализатор —
// baseband), а рисуем в координатах RX (FCenterFreq/FSpanHz). Это даёт
// правильное положение TX-сигнала при CTUN ON и выравнивает сетку, когда
// RX rate ≠ TX rate (192k).
for i := 0 to W - 1 do
begin
// freq, соответствующая пикселю i в RX-координатах
SrcF := FCenterFreq - FSpanHz * 0.5 + i * FSpanHz / Max(1, W - 1);
// смещение от центра TX-окна
Frac := SrcF - FTXFreq;
if (Frac < -FTXSpanHz * 0.5) or (Frac > FTXSpanHz * 0.5) then
dBv := -200.0 // вне диапазона TX-анализатора
else
begin
SrcF := (Frac + FTXSpanHz * 0.5) / FTXSpanHz * (SrcCount - 1);
S0 := Min(Trunc(SrcF), SrcCount - 1);
S1 := Min(S0 + 1, SrcCount - 1);
Frac := SrcF - S0;
dBv := FSpectrumBuf[S0] * (1.0 - Frac) + FSpectrumBuf[S1] * Frac;
end;
FSpPts[i] := Point(i, Max(0, Min(H-1, Round((DBmax - dBv) * InvRange * H))));
end;
end else
for i := 0 to W - 1 do
begin
SrcF := i * (SrcCount - 1.0) / Max(1, W - 1);
S0 := Min(Trunc(SrcF), SrcCount - 1);
S1 := Min(S0 + 1, SrcCount - 1);
Frac := SrcF - S0;
dBv := FSpectrumBuf[S0] * (1.0 - Frac) + FSpectrumBuf[S1] * Frac;
FSpPts[i] := Point(i, Max(0, Min(H-1, Round((DBmax - dBv) * InvRange * H))));
end;
FSpPts[W] := Point(W-1, H); FSpPts[W+1] := Point(0, H);
// ── 5. Градиент ───────────────────────────────────────────────────────────
C.Brush.Style := bsSolid; C.Pen.Style := psClear;
DrawSpectrumGradient(FSpPts, W, H);
// ── 6. Линия спектра ──────────────────────────────────────────────────────
C.Pen.Style := psSolid; C.Pen.Color := FTheme.SpecLine; C.Pen.Width := 1;
C.Polyline(Slice(FSpPts, W));
// ── 7. Края фильтра + VFO ─────────────────────────────────────────────────
C.Pen.Color := FTheme.SpecFilterEdge; C.Pen.Width := 1; C.Pen.Style := psSolid;
C.MoveTo(X1, 0); C.LineTo(X1, H);
C.MoveTo(X2, 0); C.LineTo(X2, H);
C.Pen.Color := FTheme.SpecVfoCursor; C.Pen.Width := 2;
C.MoveTo(VfoX, 0); C.LineTo(VfoX, H - 12);
C.Brush.Color := FTheme.SpecVfoCursor; C.Brush.Style := bsSolid; C.Pen.Width := 1;
C.Polygon([Point(VfoX-5,0), Point(VfoX+5,0), Point(VfoX,8)]);
// Split TX: рисуем края + cursor для TX-VFO красным, чтобы было видно где идёт передача
if FTXOverlay and (FTXVfoIndex <> FActiveVfo) then
begin
C.Pen.Color := TColor($002030E0); C.Pen.Width := 1; C.Pen.Style := psSolid;
C.MoveTo(TXX1, 0); C.LineTo(TXX1, H);
C.MoveTo(TXX2, 0); C.LineTo(TXX2, H);
C.Pen.Width := 2;
C.MoveTo(TXVfoX, 0); C.LineTo(TXVfoX, H - 12);
C.Brush.Color := TColor($002030E0); C.Brush.Style := bsSolid; C.Pen.Width := 1;
C.Polygon([Point(TXVfoX-5,0), Point(TXVfoX+5,0), Point(TXVfoX,8)]);
end;
if FMarkerActive then DrawMarkerLine(C, W, H);
DrawADCOverloadOverlay(C, W, H);
end;
// ────────────────────────────────────────────────────────────────────────────
// DrawWaterfall
// ────────────────────────────────────────────────────────────────────────────
procedure TSpectrumView.DrawWaterfall;
var
W, H, X: Integer;
dB, frac, WatSrcF: Double;
WatS0, WatS1, V: Integer;
TargetLow, TargetHigh: Double;
HistMinDB, HistMaxDB, HistStepDB: Double;
NoiseFloorDB, SignalTopDB: Double;
CumCount, LowTargetCount, HighTargetCount, HistIdx: Integer;
WfHigh, WfLow, InvRange, Step: Double;
Px: PLongWord;
Desc: TRawImageDescription;
Row, Col: Integer; RowPtr: PByte; Src: PLongWord; BPPi: Integer;
Pal: LongWord; Hist: array[0..191] of Integer; SrcCount: Integer;
const
ALPHA_HIGH = 0.10; ALPHA_LOW = 0.08;
WF_AUTO_OFFSET = -4.0; WF_MIN_RANGE = 48.0; WF_MAX_RANGE = 62.0;
begin
if FWaterfallBitmap = nil then Exit;
W := FWaterfallBitmap.Width; H := FWaterfallBitmap.Height;
if (W <= 0) or (H <= 0) then Exit;
SrcCount := EnsureRange(FWaterfallBufCount, 2, 1024);
FillChar(Hist, SizeOf(Hist), 0);
HistMinDB := -170.0; HistMaxDB := 22.0;
HistStepDB := (HistMaxDB - HistMinDB) / Length(Hist);
for X := 0 to SrcCount - 1 do
begin
HistIdx := EnsureRange(Trunc((FWaterfallBuf[X] - HistMinDB) / HistStepDB), 0, High(Hist));
Inc(Hist[HistIdx]);
end;
LowTargetCount := Round(SrcCount * 0.30);
HighTargetCount := Round(SrcCount * 0.98);
CumCount := 0; NoiseFloorDB := FWfLow; SignalTopDB := FWfHigh;
for HistIdx := 0 to High(Hist) do
begin
CumCount := CumCount + Hist[HistIdx];
if CumCount >= LowTargetCount then
begin NoiseFloorDB := HistMinDB + (HistIdx + 0.5) * HistStepDB; Break; end;
end;
CumCount := 0;
for HistIdx := 0 to High(Hist) do
begin
CumCount := CumCount + Hist[HistIdx];
if CumCount >= HighTargetCount then
begin SignalTopDB := HistMinDB + (HistIdx + 0.5) * HistStepDB; Break; end;
end;
if FWfAGCEnabled then
begin
TargetLow := NoiseFloorDB + WF_AUTO_OFFSET + FWfAGCOffset;
if FWfNFEnabled then
TargetHigh := Max(TargetLow + WF_MIN_RANGE, SignalTopDB + 6.0)
else
TargetHigh := TargetLow + 52.0;
if TargetHigh > TargetLow + WF_MAX_RANGE then TargetHigh := TargetLow + WF_MAX_RANGE;
FWfLow := FWfLow + ALPHA_LOW * (TargetLow - FWfLow);
FWfHigh := FWfHigh + ALPHA_HIGH * (TargetHigh - FWfHigh);
end;
WfHigh := FWfHigh; WfLow := FWfLow;
if not FWfAGCEnabled then begin WfHigh := FWfManualHigh; WfLow := FWfManualLow; end;
if WfHigh < WfLow + 40.0 then WfHigh := WfLow + 40.0;
if WfHigh > 0.0 then WfHigh := 0.0;
if WfLow < -160 then WfLow := -160;
InvRange := 255.0 / (WfHigh - WfLow);
if (FWfBitmapW <> W) or (FWfBitmapH <> H) then
begin
FWfBitmapW := W; FWfBitmapH := H;
SetLength(FWfPixels, W * H);
// TColor = $00BBGGRR → waterfall pixel = $FF_RR_GG_BB (swap R и B)
Pal := $FF000000
or ((LongWord(FTheme.Panel) and $FF) shl 16)
or (LongWord(FTheme.Panel) and $FF00)
or ((LongWord(FTheme.Panel) shr 16) and $FF);
FillDWord(FWfPixels[0], W * H, Pal);
FWfBitmap.SetSize(W, H);
FreeAndNil(FWfIntfImg);
end;
if H > 1 then Move(FWfPixels[0], FWfPixels[W], (H - 1) * SizeOf(LongWord) * W);
Step := (SrcCount - 1.0) / Max(1, W - 1);
WatSrcF := 0.0; Px := @FWfPixels[0];
for X := 0 to W - 1 do
begin
if FTXMode and (FTXSpanHz > 0) and (FSpanHz > 0) then
begin
// частота для пикселя X в RX-координатах → индекс в TX-буфере
dB := FCenterFreq - FSpanHz * 0.5 + X * FSpanHz / Max(1, W - 1) - FTXFreq;
if (dB < -FTXSpanHz * 0.5) or (dB > FTXSpanHz * 0.5) then
begin
// Вне TX-окна: копируем пиксель из предыдущей строки (она лежит на
// расстоянии W в FWfPixels — Move уже сдвинул её ниже). Так старая
// RX-картинка продолжается без чёрной "дыры", пока TX рисует своё
// окно в центре.
if H > 1 then Px^ := FWfPixels[X + W]
else Px^ := 0;
Inc(Px);
WatSrcF := WatSrcF + Step;
Continue;
end;
frac := (dB + FTXSpanHz * 0.5) / FTXSpanHz * (SrcCount - 1);
WatS0 := Trunc(frac);
if WatS0 > SrcCount - 2 then WatS0 := SrcCount - 2;
WatS1 := WatS0 + 1;
frac := frac - WatS0;
dB := FWaterfallBuf[WatS0] * (1.0 - frac) + FWaterfallBuf[WatS1] * frac;
end else
begin
WatS0 := Trunc(WatSrcF);
if WatS0 > SrcCount - 2 then WatS0 := SrcCount - 2;
WatS1 := WatS0 + 1;
frac := WatSrcF - WatS0;
dB := FWaterfallBuf[WatS0] * (1.0 - frac) + FWaterfallBuf[WatS1] * frac;
end;
V := Trunc((dB - WfLow) * InvRange);
if V < 0 then V := 0; if V > 255 then V := 255;
if FLightTheme then
Pal := WaterfallLightTheme(dB, WfLow, WfHigh)
else
Pal := WaterfallEnhancedColorThetis(dB, WfLow, WfHigh);
Px^ := Pal; Inc(Px);
WatSrcF := WatSrcF + Step;
end;
if FWfIntfImg = nil then
begin
FWfIntfImg := TLazIntfImage.Create(W, H);
InitRawDesc32(Desc, W, H);
FWfIntfImg.DataDescription := Desc;
FWfIntfImg.CreateData;
end;
BPPi := FWfIntfImg.DataDescription.BitsPerPixel div 8;
if (BPPi = 4) and (FWfIntfImg.PixelData <> nil) then
Move(FWfPixels[0], FWfIntfImg.PixelData^, W * H * 4)
else
begin
Src := @FWfPixels[0];
for Row := 0 to H - 1 do
begin
RowPtr := FWfIntfImg.GetDataLineStart(Row);
if RowPtr = nil then begin Inc(Src, W); Continue; end;
for Col := 0 to W - 1 do
begin
RowPtr[0] := Byte(Src^); RowPtr[1] := Byte(Src^ shr 8);
RowPtr[2] := Byte(Src^ shr 16);
if BPPi >= 4 then RowPtr[3] := $FF;
Inc(Src); Inc(RowPtr, BPPi);
end;
end;
end;
FWfBitmap.LoadFromIntfImage(FWfIntfImg);
end;
// ────────────────────────────────────────────────────────────────────────────
// DrawRuler
// ────────────────────────────────────────────────────────────────────────────
procedure TSpectrumView.DrawRuler;
var
C: TCanvas; W, H, i, X: Integer;
FreqStart, FreqHz, GridLine, pixPerStep: Double; VfoX: Integer;
Lbl: string; TW: Integer;
N, labelMult: Integer;
begin
if FPbRuler = nil then Exit;
W := FPbRuler.Width; H := FPbRuler.Height;
if (W <= 0) or (H <= 0) then Exit;
if (Abs(FCenterFreq - FRulerLastFreq) < 0.5) and
(Abs(ActiveVfoFreq - FRulerLastVfo) < 0.5) and
(Abs(FSpanHz - FRulerLastSpan) < 1.0) then Exit;
FRulerLastFreq := FCenterFreq; FRulerLastVfo := ActiveVfoFreq; FRulerLastSpan := FSpanHz;
if (FRulerBitmap.Width <> W) or (FRulerBitmap.Height <> H) then
FRulerBitmap.SetSize(W, H);
C := FRulerBitmap.Canvas;
PaintVerticalGradient(C, W, H, FTheme.RulerGradTop, FTheme.RulerGradBot);
C.Pen.Color := FTheme.RulerBorder; C.Pen.Width := 1;
C.MoveTo(0, 0); C.LineTo(W, 0);
C.MoveTo(0, H-1); C.LineTo(W, H-1);
C.Font.Name := 'Courier New'; C.Font.Size := 7;
C.Brush.Style := bsClear;
FreqStart := FCenterFreq - FSpanHz / 2;
if FFMGridStepHz > 0 then
begin
pixPerStep := W * FFMGridStepHz / FSpanHz;
if pixPerStep >= 1.0 then
labelMult := Max(1, Ceil((C.TextWidth('000.000') + 6) / pixPerStep))
else
labelMult := MaxInt;
N := Ceil(FreqStart / FFMGridStepHz);
GridLine := N * FFMGridStepHz;
while GridLine <= FCenterFreq + FSpanHz / 2 + 0.5 do
begin
X := Round((GridLine - FreqStart) / FSpanHz * W);
if (X >= 0) and (X < W) then
begin
C.Pen.Color := FTheme.RulerBorder;
if (N mod labelMult) = 0 then
begin
C.MoveTo(X, 0); C.LineTo(X, H div 2);
Lbl := Format('%.3f', [GridLine / 1e6]);
TW := C.TextWidth(Lbl);
C.Font.Color := FTheme.RulerText;
C.TextOut(X - TW div 2, H div 2 - 1, Lbl);
end else
begin
C.MoveTo(X, 0); C.LineTo(X, H div 4);
end;
end;
Inc(N);
GridLine := GridLine + FFMGridStepHz;
end;
end else
begin
for i := 0 to 8 do
begin
X := ScaleX(i, 8, W);
C.Pen.Color := FTheme.RulerBorder;
C.MoveTo(X, 0); C.LineTo(X, H div 2);
FreqHz := FreqStart + i * FSpanHz / 8;
Lbl := Format('%.3f', [FreqHz / 1e6]);
TW := C.TextWidth(Lbl);
C.Font.Color := FTheme.RulerText;
C.TextOut(X - TW div 2, H div 2 - 1, Lbl);
end;
end;
VfoX := Round((ActiveVfoFreq - FCenterFreq + FSpanHz/2) / FSpanHz * W);
if (VfoX >= 0) and (VfoX < W) then
begin
C.Pen.Color := FTheme.RulerVfo; C.Pen.Width := 2;
C.MoveTo(VfoX, 0); C.LineTo(VfoX, H - 1); C.Pen.Width := 1;
end;
end;
// ────────────────────────────────────────────────────────────────────────────
// DrawBarMeter, DrawSMeterWide, DrawSMeterZone
// ────────────────────────────────────────────────────────────────────────────
procedure TSpectrumView.DrawBarMeter(ACanvas: TCanvas; R: TRect;
Value, MaxVal: Double; BarColor: TColor);
var Pct, BarW: Integer;
begin
ACanvas.Brush.Color := FTheme.SMeterBG;
ACanvas.FillRect(R);
if MaxVal > 0 then
Pct := Round(Max(0.0, Min(1.0, Value / MaxVal)) * (R.Right - R.Left - 2))
else
Pct := 0;
BarW := Pct;
ACanvas.Brush.Color := BarColor; ACanvas.Pen.Color := BarColor;
ACanvas.FillRect(Rect(R.Left+1, R.Top+1, R.Left+1+BarW, R.Bottom-1));
ACanvas.Brush.Style := bsClear;
ACanvas.Pen.Color := SV_CLR_BORDER;
ACanvas.Rectangle(R);
end;
procedure TSpectrumView.DrawSMeterWide(ACanvas: TCanvas; R: TRect;
Value, Peak, MinVal: Double; out ZX1, ZX2, ZY1, ZY2: Integer);
const
DB_MIN = -127.0; DB_MAX = -13.0; DB_S9 = -73.0; DB_OVR = -43.0;
DBM_MARKS: array[0..5] of Double = (-120,-100,-80,-60,-40,-20);
DBM_LABELS: array[0..5] of string = ('-120','-100','-80','-60','-40','-20');
S_MARKS_DBM: array[0..7] of Double = (-121,-109,-97,-85,-73,-53,-33,-13);
S_MARKS_LBL: array[0..7] of string = ('S1','S3','S5','S7','S9','+20','+40','+60');
LEFT_INFO_MIN = 62; RIGHT_PAD = 8; LABEL_PAD = 4;
TICK_LONG = 5; TICK_MED = 3;
var
CLR_SMETER_BG, CLR_BAR_GREEN, CLR_BAR_OVER: TColor;
CLR_PEAK_MARKER, CLR_TICK_GREEN, CLR_TICK_WHITE, CLR_TICK_BLUE: TColor;
CLR_LABEL_DBM, CLR_LABEL_S, CLR_SCALE_DBM: TColor;
CLR_SCALE_S_GREEN, CLR_SCALE_S_BLUE, CLR_BDR: TColor;
W, H, BX, BW, LeftInfo: Integer;
Y_BAR_TOP, Y_BAR_BOT: Integer;
BarEnd, PeakLeft, PeakRight, S9X, OvrX: Integer;
i, X, TW: Integer; Lbl: string; SNum, Over: Integer;
LblDbm, LblS: string;
TWDbm, LblZoneL, TH10, TH6, MaxTWDbm, MaxTWS, LabelNeed: Integer;
function DBtoX(dB: Double): Integer; inline;
begin
Result := BX + Round((dB - DB_MIN) / (DB_MAX - DB_MIN) * BW);
if Result < BX then Result := BX;
if Result > BX + BW then Result := BX + BW;
end;
procedure VLine(X2, Y1, Y2: Integer; C: TColor);
begin ACanvas.Pen.Color := C; ACanvas.MoveTo(X2, Y1); ACanvas.LineTo(X2, Y2); end;
procedure FillBar(X1, Y1, X2, Y2: Integer; C: TColor);
begin
ACanvas.Brush.Color := C; ACanvas.Brush.Style := bsSolid;
ACanvas.Pen.Style := psClear;
if X2 > X1 then ACanvas.FillRect(Rect(X1, Y1, X2, Y2));
ACanvas.Pen.Style := psSolid;
end;
begin
CLR_SMETER_BG := FTheme.SMeterBG;
CLR_BAR_GREEN := FTheme.SMeterBarGreen;
CLR_BAR_OVER := FTheme.SMeterBarOver;
CLR_PEAK_MARKER := FTheme.SMeterPeakMarker;
CLR_TICK_GREEN := FTheme.SMeterTickGreen;
CLR_TICK_WHITE := FTheme.SMeterTickWhite;
CLR_TICK_BLUE := FTheme.SMeterTickBlue;
CLR_LABEL_DBM := FTheme.SMeterLabelDbm;
CLR_LABEL_S := FTheme.SMeterLabelS;
CLR_SCALE_DBM := FTheme.SMeterScaleDbm;
CLR_SCALE_S_GREEN:= FTheme.SMeterScaleSGreen;
CLR_SCALE_S_BLUE := FTheme.SMeterScaleSBlue;
CLR_BDR := FTheme.SMeterBdr;
W := R.Right - R.Left; H := R.Bottom - R.Top;
ZX1 := 0; ZX2 := 0; ZY1 := 0; ZY2 := 0;
if (W < 80) or (H < 16) then Exit;
// --- Вычисляем строки надписей заранее чтобы измерить ширину ---
LblDbm := Format('%.1f', [Value]);
if Value >= -73.0 then
begin
Over := Round(Value - (-73.0));
if Over < 5 then LblS := 'S9'
else begin Over := ((Over + 5) div 10) * 10; if Over = 0 then Over := 10; LblS := Format('S9+%d', [Over]); end;
end else if Value <= -121.0 then LblS := 'S1'
else begin
LblS := 'S1';
for SNum := 1 to 8 do
if Value >= (-121.0 + (SNum - 1) * 6.0) then LblS := 'S' + IntToStr(SNum);
end;
// Измеряем ширину надписей
ACanvas.Font.Name := 'Courier New'; ACanvas.Font.Style := [fsBold];
ACanvas.Font.Size := 10; TWDbm := ACanvas.TextWidth(LblDbm);
MaxTWDbm := ACanvas.TextWidth('-120.0');
MaxTWS := ACanvas.TextWidth('S9+60');
ACanvas.Font.Size := 6; ACanvas.Font.Style := [];
// Левая информационная зона зависит от реальных метрик шрифта.
LabelNeed := Max(MaxTWDbm + 2 + ACanvas.TextWidth('dBm '), MaxTWS) + LABEL_PAD * 2;
LeftInfo := Max(LEFT_INFO_MIN, LabelNeed + 4);
BX := R.Left + LeftInfo; BW := W - LeftInfo - RIGHT_PAD;
if BW < 10 then Exit;
LblZoneL := BX - LabelNeed;
Y_BAR_TOP := R.Top + H * 44 div 100;
Y_BAR_BOT := R.Top + H * 58 div 100;
S9X := DBtoX(DB_S9); OvrX := DBtoX(DB_OVR);
ACanvas.Brush.Color := CLR_SMETER_BG; ACanvas.Brush.Style := bsSolid;
ACanvas.Pen.Style := psClear; ACanvas.FillRect(R); ACanvas.Pen.Style := psSolid;
FillBar(BX, Y_BAR_TOP, S9X, Y_BAR_BOT, CLR_BAR_GREEN);
FillBar(S9X, Y_BAR_TOP, OvrX, Y_BAR_BOT, CLR_BAR_GREEN);
FillBar(OvrX, Y_BAR_TOP, BX + BW, Y_BAR_BOT, CLR_BAR_OVER);
BarEnd := DBtoX(Value);
PeakLeft := DBtoX(MinVal);
PeakRight := DBtoX(Peak);
ZX1 := Max(BX + 1, Min(PeakLeft, PeakRight));
ZX2 := Min(BX + BW - 1, Max(PeakLeft, PeakRight));
ZY1 := Y_BAR_TOP + 1; ZY2 := Y_BAR_BOT - 1;
if BarEnd > BX then
begin
if BarEnd <= S9X then
FillBar(BX, Y_BAR_TOP+1, BarEnd, Y_BAR_BOT-1, CLR_BAR_GREEN)
else begin
FillBar(BX, Y_BAR_TOP+1, S9X, Y_BAR_BOT-1, CLR_BAR_GREEN);
FillBar(S9X, Y_BAR_TOP+1, BarEnd, Y_BAR_BOT-1, CLR_BAR_OVER);
end;
end;
if (PeakRight > BX) and (PeakRight <= BX + BW) then
begin
ACanvas.Pen.Color := CLR_PEAK_MARKER; ACanvas.Pen.Width := 2;
ACanvas.MoveTo(PeakRight, Y_BAR_TOP - (Y_BAR_BOT - Y_BAR_TOP));
ACanvas.LineTo(PeakRight, Y_BAR_BOT + (Y_BAR_BOT - Y_BAR_TOP));
ACanvas.Pen.Width := 1;
end;
// Рамка: от левого края зоны надписей до правого края шкалы
ACanvas.Brush.Style := bsClear; ACanvas.Pen.Color := CLR_BDR;
ACanvas.Rectangle(LblZoneL, Y_BAR_TOP, BX + BW, Y_BAR_BOT);
// разделитель между зоной надписей и шкалой
ACanvas.Pen.Color := FTheme.SMeterDivider;
ACanvas.MoveTo(BX, Y_BAR_TOP + 1); ACanvas.LineTo(BX, Y_BAR_BOT - 1);
// --- Шкальные тики и надписи ---
ACanvas.Font.Size := 6; ACanvas.Font.Style := []; ACanvas.Brush.Style := bsClear;
for i := 0 to High(DBM_MARKS) do
begin
X := DBtoX(DBM_MARKS[i]);
VLine(X, Y_BAR_TOP - TICK_LONG, Y_BAR_TOP - 1, CLR_TICK_WHITE);
Lbl := DBM_LABELS[i]; TW := ACanvas.TextWidth(Lbl);
ACanvas.Font.Color := CLR_SCALE_DBM;
ACanvas.TextOut(X - TW div 2, Y_BAR_TOP - TICK_LONG - ACanvas.TextHeight(Lbl) - 1, Lbl);
end;
i := -125;
while i < -13 do
begin
X := DBtoX(i); VLine(X, Y_BAR_TOP - TICK_MED, Y_BAR_TOP - 1, CLR_TICK_GREEN);
Inc(i, 5);
end;
for i := 0 to High(S_MARKS_DBM) do
begin
X := DBtoX(S_MARKS_DBM[i]);
if S_MARKS_DBM[i] >= DB_S9 then VLine(X, Y_BAR_BOT+1, Y_BAR_BOT+TICK_LONG, CLR_TICK_BLUE)
else VLine(X, Y_BAR_BOT+1, Y_BAR_BOT+TICK_LONG, CLR_TICK_GREEN);
Lbl := S_MARKS_LBL[i]; TW := ACanvas.TextWidth(Lbl);
if S_MARKS_DBM[i] >= DB_S9 then ACanvas.Font.Color := CLR_SCALE_S_BLUE
else ACanvas.Font.Color := CLR_SCALE_S_GREEN;
ACanvas.TextOut(X - TW div 2, Y_BAR_BOT + TICK_LONG + 1, Lbl);
end;
// --- Надписи в левой зоне, левовыровнены к LblZoneL+4 ---
ACanvas.Brush.Style := bsClear;
ACanvas.Font.Size := 10; ACanvas.Font.Style := [fsBold]; TH10 := ACanvas.TextHeight('0');
ACanvas.Font.Size := 6; ACanvas.Font.Style := []; TH6 := ACanvas.TextHeight('0');
// "-85.3" над "dBm", снизу прижаты к верхнему краю полосы
ACanvas.Font.Size := 10; ACanvas.Font.Style := [fsBold]; ACanvas.Font.Color := CLR_LABEL_DBM;
ACanvas.TextOut(LblZoneL + 4, Y_BAR_TOP - TH10 - 2, LblDbm);
ACanvas.Font.Size := 6; ACanvas.Font.Style := []; ACanvas.Font.Color := CLR_TICK_BLUE;
ACanvas.TextOut(LblZoneL + 4 + TWDbm + 2, Y_BAR_TOP - TH6 - 4, 'dBm');
// S-значение под полосой, левый край у LblZoneL+4
ACanvas.Font.Size := 10; ACanvas.Font.Style := [fsBold]; ACanvas.Font.Color := CLR_LABEL_S;
ACanvas.TextOut(LblZoneL + 4, Y_BAR_BOT + 2, LblS);
end;
procedure TSpectrumView.DrawSMeterZone(ACanvas: TCanvas; R: TRect;
X1, X2, Y1, Y2: Integer);
const ALPHA = 16384;
var Img: TLazIntfImage; FC: TFPColor; IX, IY: Integer;
begin
if (X2 <= X1) or (FSmBitmap = nil) then Exit;
Img := FSmBitmap.CreateIntfImage;
try
for IY := Y1 to Y2 - 1 do
for IX := X1 to X2 - 1 do
begin
FC := Img.Colors[IX, IY];
FC.Red := Min(65535, FC.Red + ALPHA);
FC.Green := Min(65535, FC.Green + ALPHA);
FC.Blue := Min(65535, FC.Blue + ALPHA);
Img.Colors[IX, IY] := FC;
end;
FSmBitmap.LoadFromIntfImage(Img);
finally Img.Free; end;
end;
procedure TSpectrumView.DrawTXMeter(ACanvas: TCanvas; R: TRect);
// Визуально идентичен DrawSMeterWide: те же пропорции, тот же левый блок,
// те же тики — но центральная полоса разбита на две: мощность (верх) и КСВ (низ).
const
SWR_HIGH_THRESH = 2.5;
SWR_MAX = 10.0;
LEFT_INFO_MIN = 62; RIGHT_PAD = 8; LABEL_PAD = 4;
TICK_LONG = 5; TICK_MED = 3;
PWR_MARKS: array[0..4] of Double = (0.0, 0.25, 0.5, 0.75, 1.0);
SWR_MARKS_VAL: array[0..3] of Double = (1.0, 2.5, 5.0, 10.0);
SWR_MARKS_LBL: array[0..3] of string = ('1', '2.5', '5', '10');
var
CLR_SMETER_BG, CLR_BAR_GREEN, CLR_BAR_OVER: TColor;
CLR_TICK_GREEN, CLR_TICK_WHITE, CLR_TICK_BLUE: TColor;
CLR_LABEL_DBM, CLR_LABEL_S: TColor;
CLR_SCALE_DBM, CLR_SCALE_S_GREEN, CLR_SCALE_S_BLUE, CLR_BDR: TColor;
CLR_SWR_HIGH: TColor;
W, H, BX, BW, LeftInfo: Integer;
Y_BAR_TOP, Y_BAR_BOT, Y_BAR_MID: Integer;
PY1, PY2, SY1, SY2: Integer;
PBarEnd, SBarEnd, S_WarnX: Integer;
LblPwr, LblSWR: string;
TWPwr, LblZoneL, TH10, TH6, MaxTWPwr, MaxTWSWR, LabelNeed: Integer;
i, X, TW: Integer;
Lbl: string;
SWRHigh: Boolean;
PwrPct: Double;
function PwrToX(Pct: Double): Integer; inline;
begin
Result := BX + Round(Max(0.0, Min(1.0, Pct)) * BW);
if Result < BX then Result := BX;
if Result > BX + BW then Result := BX + BW;
end;
function SWRToX(SWR: Double): Integer; inline;
begin
Result := BX + Round(Max(0.0, Min(1.0, (SWR - 1.0) / (SWR_MAX - 1.0))) * BW);
if Result < BX then Result := BX;
if Result > BX + BW then Result := BX + BW;
end;
procedure VLine(X2, Y1, Y2: Integer; C: TColor);
begin ACanvas.Pen.Color := C; ACanvas.MoveTo(X2, Y1); ACanvas.LineTo(X2, Y2); end;
procedure FillBar(X1, Y1, X2, Y2: Integer; C: TColor);
begin
ACanvas.Brush.Color := C; ACanvas.Brush.Style := bsSolid;
ACanvas.Pen.Style := psClear;
if X2 > X1 then ACanvas.FillRect(Rect(X1, Y1, X2, Y2));
ACanvas.Pen.Style := psSolid;
end;
begin
CLR_SMETER_BG := FTheme.SMeterBG;
CLR_BAR_GREEN := FTheme.SMeterBarGreen;
CLR_BAR_OVER := FTheme.SMeterBarOver;
CLR_TICK_GREEN := FTheme.SMeterTickGreen;
CLR_TICK_WHITE := FTheme.SMeterTickWhite;
CLR_TICK_BLUE := FTheme.SMeterTickBlue;
CLR_LABEL_DBM := FTheme.SMeterLabelDbm;
CLR_LABEL_S := FTheme.SMeterLabelS;
CLR_SCALE_DBM := FTheme.SMeterScaleDbm;
CLR_SCALE_S_GREEN:= FTheme.SMeterScaleSGreen;
CLR_SCALE_S_BLUE := FTheme.SMeterScaleSBlue;
CLR_BDR := FTheme.SMeterBdr;
CLR_SWR_HIGH := TColor($000000CC);
W := R.Right - R.Left; H := R.Bottom - R.Top;
if (W < 80) or (H < 16) then Exit;
SWRHigh := FLastSWR > SWR_HIGH_THRESH;
LblPwr := Format('%.0fW', [FLastFwdW]);
LblSWR := Format('SWR:%.1f', [FLastSWR]);
// Левая информационная зона — такая же логика как в DrawSMeterWide
ACanvas.Font.Name := 'Courier New'; ACanvas.Font.Style := [fsBold];
ACanvas.Font.Size := 10;
TWPwr := ACanvas.TextWidth(LblPwr);
MaxTWPwr := ACanvas.TextWidth(Format('%.0fW', [FPAMaxPower]));
MaxTWSWR := ACanvas.TextWidth('SWR:10.0');
ACanvas.Font.Size := 6; ACanvas.Font.Style := [];
LabelNeed := Max(MaxTWPwr, MaxTWSWR) + LABEL_PAD * 2;
LeftInfo := Max(LEFT_INFO_MIN, LabelNeed + 4);
BX := R.Left + LeftInfo; BW := W - LeftInfo - RIGHT_PAD;
if BW < 10 then Exit;
LblZoneL := BX - LabelNeed;
// Те же вертикальные позиции бара что в DrawSMeterWide (44% / 58%)
Y_BAR_TOP := R.Top + H * 44 div 100;
Y_BAR_BOT := R.Top + H * 58 div 100;
Y_BAR_MID := (Y_BAR_TOP + Y_BAR_BOT) div 2;
// Два суб-бара в той же зоне
PY1 := Y_BAR_TOP;
PY2 := Y_BAR_MID - 1; // power bar
SY1 := Y_BAR_MID + 1;
SY2 := Y_BAR_BOT; // SWR bar
S_WarnX := SWRToX(SWR_HIGH_THRESH);
// Фон
ACanvas.Brush.Color := CLR_SMETER_BG; ACanvas.Brush.Style := bsSolid;
ACanvas.Pen.Style := psClear; ACanvas.FillRect(R); ACanvas.Pen.Style := psSolid;
// Фоновые зоны (как зоны зелёный/красный в S-метре)
FillBar(BX, PY1, BX + BW, PY2, CLR_BAR_GREEN); // power: весь зелёный фон
FillBar(BX, SY1, S_WarnX, SY2, CLR_BAR_GREEN); // SWR: OK-зона
FillBar(S_WarnX, SY1, BX + BW, SY2, CLR_BAR_OVER); // SWR: warn-зона
// Активный заполненный бар мощности
if FPAMaxPower > 0 then PwrPct := FLastFwdW / FPAMaxPower else PwrPct := 0;
PBarEnd := PwrToX(PwrPct);
FillBar(BX, PY1 + 1, PBarEnd, PY2 - 1, SV_CLR_METER_ON);
// Активный заполненный бар КСВ
SBarEnd := SWRToX(FLastSWR);
if not SWRHigh then
FillBar(BX, SY1 + 1, SBarEnd, SY2 - 1, SV_CLR_AMBER)
else
begin
FillBar(BX, SY1 + 1, Min(S_WarnX, SBarEnd), SY2 - 1, SV_CLR_AMBER);
if SBarEnd > S_WarnX then
FillBar(S_WarnX, SY1 + 1, SBarEnd, SY2 - 1, CLR_SWR_HIGH);
end;
// Граница KСВ 2.5
VLine(S_WarnX, SY1, SY2, FTheme.SMeterDivider);
// Рамки обоих баров + левая зона надписей
ACanvas.Brush.Style := bsClear; ACanvas.Pen.Color := CLR_BDR;
ACanvas.Rectangle(LblZoneL, PY1, BX + BW, PY2);
if SWRHigh then ACanvas.Pen.Color := CLR_SWR_HIGH
else ACanvas.Pen.Color := CLR_BDR;
ACanvas.Rectangle(LblZoneL, SY1, BX + BW, SY2);
// Разделители между зоной надписей и шкалой
ACanvas.Pen.Color := FTheme.SMeterDivider;
ACanvas.MoveTo(BX, PY1 + 1); ACanvas.LineTo(BX, PY2 - 1);
ACanvas.MoveTo(BX, SY1 + 1); ACanvas.LineTo(BX, SY2 - 1);
// --- Тики шкалы ---
ACanvas.Font.Size := 6; ACanvas.Font.Style := []; ACanvas.Brush.Style := bsClear;
// Мощность: промежуточные тики каждые 5% (не на главных метках кратных 25%)
i := 5;
while i < 100 do
begin
if (i mod 25) <> 0 then
begin
X := PwrToX(i / 100.0);
VLine(X, PY1 - TICK_MED, PY1 - 1, CLR_TICK_GREEN);
end;
Inc(i, 5);
end;
// Мощность: главные тики с подписями (0%, 25%, 50%, 75%, 100%)
for i := 0 to 4 do
begin
X := PwrToX(PWR_MARKS[i]);
VLine(X, PY1 - TICK_LONG, PY1 - 1, CLR_TICK_WHITE);
Lbl := Format('%.0f', [PWR_MARKS[i] * FPAMaxPower]);
TW := ACanvas.TextWidth(Lbl);
ACanvas.Font.Color := CLR_SCALE_DBM;
ACanvas.TextOut(X - TW div 2, PY1 - TICK_LONG - ACanvas.TextHeight(Lbl) - 1, Lbl);
end;
// КСВ: промежуточные тики на целых значениях 2,3,4,6,7,8,9
for i := 2 to 9 do
begin
if i = 5 then Continue;
X := SWRToX(i);
if i >= 3 then VLine(X, SY2 + 1, SY2 + TICK_MED, CLR_TICK_BLUE)
else VLine(X, SY2 + 1, SY2 + TICK_MED, CLR_TICK_GREEN);
end;
// КСВ: главные тики с подписями (1, 2.5, 5, 10)
for i := 0 to 3 do
begin
X := SWRToX(SWR_MARKS_VAL[i]);
if SWR_MARKS_VAL[i] >= SWR_HIGH_THRESH then
VLine(X, SY2 + 1, SY2 + TICK_LONG, CLR_TICK_BLUE)
else
VLine(X, SY2 + 1, SY2 + TICK_LONG, CLR_TICK_GREEN);
Lbl := SWR_MARKS_LBL[i]; TW := ACanvas.TextWidth(Lbl);
if SWR_MARKS_VAL[i] >= SWR_HIGH_THRESH then
begin
if SWRHigh then ACanvas.Font.Color := CLR_SWR_HIGH
else ACanvas.Font.Color := CLR_SCALE_S_BLUE;
end else
ACanvas.Font.Color := CLR_SCALE_S_GREEN;
ACanvas.TextOut(X - TW div 2, SY2 + TICK_LONG + 1, Lbl);
end;
// --- Надписи в левой зоне (те же позиции что в DrawSMeterWide) ---
ACanvas.Brush.Style := bsClear;
ACanvas.Font.Size := 10; ACanvas.Font.Style := [fsBold]; TH10 := ACanvas.TextHeight('0');
ACanvas.Font.Size := 6; ACanvas.Font.Style := []; TH6 := ACanvas.TextHeight('0');
// Мощность — над верхним баром, как dBm в S-метре
ACanvas.Font.Size := 10; ACanvas.Font.Style := [fsBold]; ACanvas.Font.Color := CLR_LABEL_DBM;
ACanvas.TextOut(LblZoneL + 4, PY1 - TH10 - 2, LblPwr);
// КСВ — под нижним баром, как S-значение в S-метре
ACanvas.Font.Size := 10; ACanvas.Font.Style := [fsBold];
if SWRHigh then ACanvas.Font.Color := CLR_SWR_HIGH
else ACanvas.Font.Color := CLR_LABEL_S;
ACanvas.TextOut(LblZoneL + 4, SY2 + 2, LblSWR);
// "SWR High" рядом с надписью мощности (как "dBm" в S-метре)
if SWRHigh then
begin
ACanvas.Font.Size := 6; ACanvas.Font.Style := [fsBold]; ACanvas.Font.Color := CLR_SWR_HIGH;
ACanvas.TextOut(LblZoneL + 4 + TWPwr + 2, PY1 - TH6 - 4, 'SWR High');
end;
end;
// ────────────────────────────────────────────────────────────────────────────
// Paint-обработчики
// ────────────────────────────────────────────────────────────────────────────
procedure TSpectrumView.PaintSpectrum(Sender: TObject);
var PB: TPaintBox;
begin
PB := TPaintBox(Sender);
if (FSpectrumBitmap.Width > 0) and (FSpectrumBitmap.Height > 0) then
PB.Canvas.Draw(0, 0, FSpectrumBitmap)
else begin
PB.Canvas.Brush.Color := SV_CLR_BG;
PB.Canvas.FillRect(Rect(0, 0, PB.Width, PB.Height));
end;
end;
procedure TSpectrumView.PaintWaterfall(Sender: TObject);
var PB: TPaintBox; W, H: Integer;
begin
PB := TPaintBox(Sender);
W := PB.Width; H := PB.Height;
if (W <= 0) or (H <= 0) then Exit;
if (FWfBitmap <> nil) and (FWfBitmap.Width = W) and (FWfBitmap.Height = H) then
PB.Canvas.Draw(0, 0, FWfBitmap)
else begin
PB.Canvas.Brush.Color := FTheme.Panel;
PB.Canvas.FillRect(Rect(0, 0, W, H));
end;
if FMarkerActive then DrawMarkerLine(PB.Canvas, W, H);
end;
procedure TSpectrumView.PaintRuler(Sender: TObject);
var PB: TPaintBox;
begin
PB := TPaintBox(Sender);
DrawRuler;
if (FRulerBitmap.Width > 0) and (FRulerBitmap.Height > 0) then
PB.Canvas.Draw(0, 0, FRulerBitmap);
end;
procedure TSpectrumView.PaintSMeterRight(Sender: TObject);
var PB: TPaintBox; W, H, ZX1, ZX2, ZY1, ZY2: Integer;
begin
PB := TPaintBox(Sender);
W := PB.Width; H := PB.Height;
if (W <= 0) or (H <= 0) then Exit;
if (FSmBitmap = nil) or (FSmBitmap.Width <> W) or (FSmBitmap.Height <> H) then
begin
FreeAndNil(FSmBitmap);
FSmBitmap := TBitmap.Create;
FSmBitmap.SetSize(W, H);
end;
if FTransmitting then
begin
DrawTXMeter(FSmBitmap.Canvas, Rect(0, 0, W, H));
end else
begin
DrawSMeterWide(FSmBitmap.Canvas, Rect(0, 0, W, H),
FLastSMeter, FSMeterPeak, FSMeterMin,
ZX1, ZX2, ZY1, ZY2);
DrawSMeterZone(FSmBitmap.Canvas, Rect(0, 0, W, H), ZX1, ZX2, ZY1, ZY2);
end;
PB.Canvas.Draw(0, 0, FSmBitmap);
end;
procedure TSpectrumView.PaintFwdPower(Sender: TObject);
var PB: TPaintBox;
begin
PB := TPaintBox(Sender);
DrawBarMeter(PB.Canvas, Rect(0, 0, PB.Width, PB.Height),
FLastFwdW, 150, SV_CLR_METER_ON);
end;
procedure TSpectrumView.PaintSWR(Sender: TObject);
var PB: TPaintBox;
begin
PB := TPaintBox(Sender);
DrawBarMeter(PB.Canvas, Rect(0, 0, PB.Width, PB.Height),
FLastSWR - 1.0, 4.0, SV_CLR_AMBER);
end;
end.