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;

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;
    FRulerBitmap:     TBitmap;
    FSpecGradImg:     TLazIntfImage;
    FSpecGradBmp:     TBitmap;
    FWfIntfImg:       TLazIntfImage;
    FWfPixels:        array of LongWord;
    FSpPts:           array of TPoint;
    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;

    // ── Marker ────────────────────────────────────────────────────────────────
    FMarkerActive:  Boolean;
    FMarkerX:       Integer;

    // ── Meter values ──────────────────────────────────────────────────────────
    FLastSMeter:  Double;
    FSMeterPeak:  Double;
    FSMeterMin:   Double;
    FLastFwdW:    Double;
    FLastSWR:     Double;

    // ── Буферы данных (пишутся из 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 DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer);
    procedure DrawMarkerLine(C: TCanvas; W, H: 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 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 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);

    // ── Управление размером 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 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;

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, KHz, Rest: Integer;
begin
  Mhz  := Trunc(Hz / 1000000);
  KHz  := Trunc((Hz - Mhz * 1000000) / 1000);
  Rest := Trunc(Hz) mod 1000;
  Result := Format('%3d.%3.3d.%3.3d', [Mhz, KHz, Rest]);
end;

// ════════════════════════════════════════════════════════════════════════════
// TSpectrumView
// ════════════════════════════════════════════════════════════════════════════

constructor TSpectrumView.Create;
begin
  inherited Create;
  FSpectrumBitmap  := TBitmap.Create;
  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;
  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;
  FSpectrumBufCount  := 1024;
  FWaterfallBufCount := 1024;
  FWfFrameInterval   := 2;
  FWfFrameCounter    := 0;
  FWaterfallDirty    := True;
  FLastSMeter  := -130; FSMeterPeak := -130; FSMeterMin := -130;
  FLastFwdW    := 0; FLastSWR := 1;
  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.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.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(Alpha shr 1); RG := Byte(Alpha);
    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] := 0;
        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 := SV_CLR_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.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;
  Lo_Hz, Hi_Hz, Half: Double;
  AGCy, AGCHangY: Integer;
  SrcF, Frac, dBv: Double;
  S0, S1: Integer;
  InvRange: Double;
  LabelBandW, SrcCount: 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) then
  begin
    FGridBitmapW := W; FGridBitmapH := H;
    FGridBitmap.SetSize(W, H);
    GC := FGridBitmap.Canvas;
    PaintVerticalGradient(GC, W, H, TColor($0020160B), TColor($006C4F1F));
    LabelBandW := 28;
    GC.Pen.Style := psClear; GC.Brush.Style := bsSolid;
    GC.Brush.Color := TColor($00543F18);
    GC.FillRect(Rect(0, 0, LabelBandW, H));
    GC.Pen.Style := psSolid;
    GC.Font.Size := 7; GC.Font.Name := 'Courier New';
    GC.Font.Color := TColor($00A8C9D9);
    GC.Brush.Style := bsClear;
    dB := DBmax - FSpecGridStep;
    while dB >= DBmin do
    begin
      Yp := Round((DBmax - dB) * InvRange * H);
      GC.Pen.Color := TColor($00685624); 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;
    for i := 0 to 8 do
    begin
      GX := ScaleX(i, 8, W);
      GC.Pen.Color := TColor($00685624);
      GC.MoveTo(GX, 0); GC.LineTo(GX, H);
    end;
  end;
  LCLIntf.BitBlt(C.Handle, 0, 0, W, H, FGridBitmap.Canvas.Handle, 0, 0, $CC0020);

  // ── 2. Полоса фильтра ─────────────────────────────────────────────────────
  Half := FFilterBW / 2;
  if FActiveVfo = 0 then
    VfoX := Round((FVfoA - FCenterFreq + FSpanHz/2) / FSpanHz * W)
  else
    VfoX := Round((FVfoB - 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)));
  C.Brush.Color := TColor($006A4E22); C.Brush.Style := bsSolid; C.Pen.Style := psClear;
  if X2 > X1 then C.FillRect(Rect(X1, 0, X2, H));
  C.Pen.Style := psSolid;

  // ── 3. AGC линии ──────────────────────────────────────────────────────────
  if FWDSPReady then
  begin
    AGCy := Round((DBmax - FAGCThresh) * InvRange * H);
    if (AGCy >= 0) and (AGCy < H) then
    begin
      C.Pen.Color := TColor($0000AAFF); C.Pen.Width := 1; C.Pen.Style := psDash;
      C.MoveTo(0, AGCy); C.LineTo(W, AGCy);
      C.Pen.Style := psSolid; C.Font.Color := TColor($0000AAFF); 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 := TColor($00FFCC00); C.Pen.Width := 1; C.Pen.Style := psDot;
      C.MoveTo(0, AGCHangY); C.LineTo(W, AGCHangY);
      C.Pen.Style := psSolid; C.Font.Color := TColor($00FFCC00); 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 := TColor($0000AAFF); C.Pen.Width := 1; C.Pen.Style := psDash;
      C.MoveTo(0, AGCy); C.LineTo(W, AGCy);
      C.Pen.Style := psSolid; C.Font.Color := TColor($0000AAFF); 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);
  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 := TColor($0040FF80); C.Pen.Width := 1;
  C.Polyline(Slice(FSpPts, W));

  // ── 7. Края фильтра + VFO ─────────────────────────────────────────────────
  C.Pen.Color := TColor($0040FFCC); 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 := TColor($0000AAFF); C.Pen.Width := 2;
  C.MoveTo(VfoX, 0); C.LineTo(VfoX, H - 12);
  C.Brush.Color := TColor($0000AAFF); C.Brush.Style := bsSolid; C.Pen.Width := 1;
  C.Polygon([Point(VfoX-5,0), Point(VfoX+5,0), Point(VfoX,8)]);

  if FMarkerActive then DrawMarkerLine(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);
    FillDWord(FWfPixels[0], W * H, 0);
    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
    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;
    V     := Trunc((dB - WfLow) * InvRange);
    if V < 0 then V := 0; if V > 255 then V := 255;
    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: Double; VfoX: Integer;
  Lbl: string; TW: 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, TColor($00372A12), TColor($00705A2A));
  C.Pen.Color := TColor($00685624); 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;
  for i := 0 to 8 do
  begin
    X := ScaleX(i, 8, W);
    C.Pen.Color := TColor($00685624);
    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 := TColor($00C8D8E2);
    C.TextOut(X - TW div 2, H div 2 - 1, Lbl);
  end;
  VfoX := Round((ActiveVfoFreq - FCenterFreq + FSpanHz/2) / FSpanHz * W);
  if (VfoX >= 0) and (VfoX < W) then
  begin
    C.Pen.Color := TColor($0000FF80); 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 := TColor($00080808);
  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');
  CLR_SMETER_BG   = TColor($00181818);
  CLR_BAR_GREEN   = TColor($00091F00);
  CLR_BAR_OVER    = TColor($00091528);
  CLR_PEAK_MARKER = TColor($00F8F8FF);
  CLR_TICK_GREEN  = TColor($00708070);
  CLR_TICK_WHITE  = TColor($00E0E0DC);
  CLR_TICK_BLUE   = TColor($0070A090);
  CLR_LABEL_DBM   = TColor($0096D8DC);
  CLR_LABEL_S     = TColor($00F0F0F0);
  CLR_SCALE_DBM   = TColor($00D0DCDC);
  CLR_SCALE_S_GREEN = TColor($00E0E0E0);
  CLR_SCALE_S_BLUE  = TColor($0090D090);
  CLR_BDR         = TColor($00303028);
  LEFT_INFO = 62; TICK_LONG = 5; TICK_MED = 3;
var
  W, H, BX, BW: Integer;
  Y_BAR_TOP, Y_BAR_BOT: Integer;
  BarEnd, PeakLeft, PeakRight, S9X, OvrX: Integer;
  i, X, TW: Integer; Lbl: string; SNum, Over: 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
  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;
  BX := R.Left + LEFT_INFO; BW := W - LEFT_INFO - 4;
  Y_BAR_TOP := R.Top + H * 36 div 100;
  Y_BAR_BOT := R.Top + H * 66 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, TColor($00061006));
  FillBar(S9X,  Y_BAR_TOP, OvrX,    Y_BAR_BOT, TColor($000A1006));
  FillBar(OvrX, Y_BAR_TOP, BX + BW, Y_BAR_BOT, TColor($00060A10));

  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); ACanvas.LineTo(PeakRight, Y_BAR_BOT);
    ACanvas.Pen.Width := 1;
  end;
  ACanvas.Brush.Style := bsClear; ACanvas.Pen.Color := CLR_BDR;
  ACanvas.Rectangle(BX, Y_BAR_TOP, BX + BW, Y_BAR_BOT);

  ACanvas.Font.Name := 'Courier New'; 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;

  ACanvas.Font.Size := 8; ACanvas.Font.Style := [fsBold]; ACanvas.Font.Color := CLR_LABEL_DBM;
  Lbl := Format('%6.1f', [Value]);
  ACanvas.TextOut(R.Left + 1, R.Top + 1, Lbl);
  ACanvas.Font.Size := 6; ACanvas.Font.Style := [];
  ACanvas.Font.Color := TColor($00507070);
  ACanvas.TextOut(R.Left + 1, Y_BAR_TOP - ACanvas.TextHeight('dBm') - 1, 'dBm');

  ACanvas.Font.Size := 8; ACanvas.Font.Style := [fsBold]; ACanvas.Font.Color := CLR_LABEL_S;
  if Value >= -73.0 then
  begin
    Over := Round(Value - (-73.0));
    if Over < 5 then Lbl := 'S9'
    else begin Over := ((Over + 5) div 10) * 10; if Over = 0 then Over := 10; Lbl := Format('S9+%d', [Over]); end;
  end else if Value <= -121.0 then Lbl := 'S1'
  else begin
    Lbl := 'S1';
    for SNum := 1 to 8 do
      if Value >= (-121.0 + (SNum - 1) * 6.0) then Lbl := 'S' + IntToStr(SNum);
  end;
  ACanvas.TextOut(R.Left + 1, Y_BAR_BOT + TICK_LONG + 1, Lbl);
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;

// ────────────────────────────────────────────────────────────────────────────
// 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 := clBlack;
    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;
  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);
  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.
