feat: QO-100 NB transponder bandplan overlay on spectrum

Add a thin colored strip along the bottom of the spectrum (above the
ruler) showing the QO-100 narrow-band transponder bandplan: mode
segments (CW ONLY / NB DIGI / DIGI / SSB / MIXED MODES) plus the three
beacons (CW / PSK / EXP) as muted-red cells at their ranges.

- New unit BandPlanOverlay.pas (TBandPlanOverlay), modelled on
  VfoOverlay: content rendered once into a cache bitmap, per-frame cost
  is just a constant-alpha composite (CPU) / one textured quad (GL).
  Cache invalidates only on view change (center/span/width) so it idles
  while the QO-100 downlink display is static.
- Wired into both render paths: SpectrumView (CPU composite) and
  SpectrumViewOpengl (texture, re-uploaded only when dirty).
- MainForm: SetView fed from SyncSpecViewFreq; visibility gated by
  InQO100 (Pluto + full-duplex transponder), like RX MUTE / BEACON.
- DPI-robust: strip height scales with Screen.PixelsPerInch and the font
  height is tied to the strip height (not point size), so labels always
  fit at any Windows scaling.
- Segment/beacon frequencies and colors are a single editable table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 21:02:50 +03:00
co-authored by Claude Opus 4.8
parent 3bd37b956e
commit 432c0285f0
5 changed files with 382 additions and 6 deletions
+310
View File
@@ -0,0 +1,310 @@
unit BandPlanOverlay;
{
Оверлей бэндплана QO-100 (узкополосный транспондер) — тонкая цветная полоска
вдоль оси частот ВНИЗУ спектра (над ruler) с подписями сегментов.
Производительность — модель VfoOverlay: контент рендерится в кэш-битмап ОДИН раз
и пересобирается только при смене вида (center/span/размер/тема/enabled), а каждый
кадр идёт лишь композит полоски (W × STRIP_H) с постоянной альфой. Дорогих
попиксельных проходов на кадр нет. GL-путь берёт тот же битмап текстурой
(DrawOverlayBitmap) и заливает её только при dirty — одинаковый вид в CPU и GL,
т.к. GL-UploadBitmap поддерживает color-key + постоянную альфу (как у VfoOverlay).
Сегменты заданы таблицей в DOWNLINK-Гц (QO100_NB). Границы — по плану AMSAT-DL
NB-транспондера; правятся в одном месте.
}
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Controls, Graphics, Types, Math, Forms;
const
BANDPLAN_STRIP_BASE_H = 20; // базовая высота полоски @96dpi (чуть больше ruler=18);
// фактическая FStripH масштабируется по DPI экрана
BANDPLAN_ALPHA = 188; // глобальная альфа композита (0..255)
type
// Тип сегмента → цвет. Правится таблицей SEG_COLOR ниже.
TSegKind = (skBeacon, skCW, skSSB, skDigi, skNBDigi, skDV, skMixed);
TBandSeg = record
Lo, Hi: Double; // downlink-Гц
Name: string;
Kind: TSegKind;
end;
TBandPlanOverlay = class(TComponent)
private
FCenterFreq: Double;
FSpanHz: Double;
FEnabled: Boolean;
FCache: TBitmap; // W × STRIP_H, непрозрачный; альфа применяется при композите
FCacheW: Integer;
FCacheDirty: Boolean;
// ключ кэша — пересборка только при изменении
FKeyCenter: Double;
FKeySpan: Double;
FKeyW: Integer;
FKeyEnabled: Boolean;
FStripH: Integer; // фактическая высота полоски (px), масштаб по DPI
function FreqToX(FreqHz: Double; W: Integer): Integer;
procedure DrawSelf(C: TCanvas; W: Integer);
procedure RebuildCache(W: Integer);
function NeedsRebuild(W: Integer): Boolean;
procedure CompositeStrip(Target: TBitmap; DstY: Integer);
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
// Текущий вид спектра (downlink-домен). Дёргается при тюнинге/смене span.
procedure SetView(ACenterHz, ASpanHz: Double);
procedure SetEnabled(En: Boolean);
function Active: Boolean;
// CPU-путь: композит полоски внизу Target (над ruler).
procedure DrawOverlay(Target: TBitmap; W, H: Integer);
// GL-путь: рендер полоски в Target-битмап (W × STRIP_H) для заливки текстурой.
procedure DrawOverlayBitmap(Target: TBitmap; W: Integer);
property Enabled: Boolean read FEnabled;
property CenterFreq: Double read FCenterFreq; // для GL-инвалидизации текстуры
property SpanHz: Double read FSpanHz;
end;
implementation
const
// Палитра по типу сегмента (TColor = $00BBGGRR).
SEG_COLOR: array[TSegKind] of TColor = (
$005258C8, // skBeacon — приглушённый красный (с прохладным оттенком палитры)
$00C07820, // skCW — синий
$002F9A3A, // skSSB — зелёный
$00B0479E, // skDigi — фиолетовый (яркий)
$00794A6E, // skNBDigi — приглушённый фиолетовый (NB DIGI, тусклее DIGI)
$009A9A20, // skDV — бирюзовый
$00606060); // skMixed — серый
CLR_STRIP_BG = TColor($00181818); // фон полоски вне транспондера
CLR_STRIP_TOP = TColor($00585858); // верхняя кромка (отделить от спектра)
CLR_SEP = TColor($000C0C0C); // разделители сегментов
CLR_LBL = TColor($00F0F0F0); // подписи
// ── Бэндплан QO-100 NB-транспондера (DOWNLINK Гц) ────────────────────────────
// Транспондер 10489.50010490.000 (500 кГц). Mode-заливки между маяками; маяки —
// отдельными ярко-янтарными ячейками на своих диапазонах. Границы правятся ЗДЕСЬ.
QO100_NB: array[0..5] of TBandSeg = (
(Lo: 10489505000.0; Hi: 10489540000.0; Name: 'CW ONLY'; Kind: skCW),
(Lo: 10489540000.0; Hi: 10489580000.0; Name: 'NB DIGI'; Kind: skNBDigi),
(Lo: 10489580000.0; Hi: 10489650000.0; Name: 'DIGI'; Kind: skDigi),
(Lo: 10489650000.0; Hi: 10489745000.0; Name: 'SSB'; Kind: skSSB),
(Lo: 10489755000.0; Hi: 10489850000.0; Name: 'SSB'; Kind: skSSB),
(Lo: 10489850000.0; Hi: 10489990000.0; Name: 'MIXED MODES'; Kind: skDV));
// Маяки QO-100 (диапазоны): нижний CW, средний PSK, верхний. Ярко-янтарные ячейки.
QO100_BEACONS: array[0..2] of TBandSeg = (
(Lo: 10489500000.0; Hi: 10489505000.0; Name: 'CW'; Kind: skBeacon), // нижний CW-маяк
(Lo: 10489745000.0; Hi: 10489755000.0; Name: 'PSK'; Kind: skBeacon), // средний PSK-маяк
(Lo: 10489990000.0; Hi: 10490000000.0; Name: 'EXP'; Kind: skBeacon)); // верхний эксп. маяк
function Darken(C: TColor; Num, Den: Integer): TColor;
var r, g, b: Integer;
begin
r := (C and $FF) * Num div Den;
g := ((C shr 8) and $FF) * Num div Den;
b := ((C shr 16) and $FF) * Num div Den;
Result := TColor((b shl 16) or (g shl 8) or r);
end;
constructor TBandPlanOverlay.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
// Высота полоски масштабируется по DPI экрана (как RULER_H в MainForm), иначе на
// Windows-масштабе 125/150/200% полоска осталась бы фиксированно тонкой.
FStripH := Round(BANDPLAN_STRIP_BASE_H * Screen.PixelsPerInch / 96);
if FStripH < BANDPLAN_STRIP_BASE_H then FStripH := BANDPLAN_STRIP_BASE_H;
FCenterFreq := 10489750000.0;
FSpanHz := 500000.0;
FEnabled := False;
FCache := TBitmap.Create;
FCache.PixelFormat := pf32bit;
FCacheW := 0;
FCacheDirty := True;
FKeyCenter := -1; FKeySpan := -1; FKeyW := -1; FKeyEnabled := False;
end;
destructor TBandPlanOverlay.Destroy;
begin
FCache.Free;
inherited Destroy;
end;
procedure TBandPlanOverlay.SetView(ACenterHz, ASpanHz: Double);
begin
// Квантуем сравнение, чтобы микродрейф не вызывал постоянную пересборку.
if (Abs(ACenterHz - FCenterFreq) < 1.0) and (Abs(ASpanHz - FSpanHz) < 1.0) then Exit;
FCenterFreq := ACenterHz;
FSpanHz := ASpanHz;
FCacheDirty := True;
end;
procedure TBandPlanOverlay.SetEnabled(En: Boolean);
begin
if FEnabled = En then Exit;
FEnabled := En;
FCacheDirty := True;
end;
function TBandPlanOverlay.Active: Boolean;
begin
Result := FEnabled;
end;
function TBandPlanOverlay.FreqToX(FreqHz: Double; W: Integer): Integer;
begin
if FSpanHz <= 0 then Result := -1
else Result := Round((FreqHz - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W);
end;
procedure TBandPlanOverlay.DrawSelf(C: TCanvas; W: Integer);
// Рендерит полоску в канвас битмапа W × FStripH, начало (0,0), НЕПРОЗРАЧНО.
// Прозрачность накладывается позже (CompositeStrip / GL FixedAlpha).
var
i, ty, th, SH: Integer;
// Единый стиль для всех ячеек (mode-сегменты И маяки): приглушённая заливка +
// яркая кромка цвета сверху + разделитель слева + подпись по центру если влезает.
procedure DrawSeg(const seg: TBandSeg);
var x1, x2, cx, tw: Integer; col: TColor;
begin
x1 := FreqToX(seg.Lo, W);
x2 := FreqToX(seg.Hi, W);
if (x2 <= 0) or (x1 >= W) then Exit; // вне экрана
if x1 < 0 then x1 := 0;
if x2 > W then x2 := W;
if x2 <= x1 then x2 := x1 + 1; // минимум 1px видимости (узкие маяки)
col := SEG_COLOR[seg.Kind];
C.Brush.Color := Darken(col, 3, 5); C.Brush.Style := bsSolid; C.Pen.Style := psClear;
C.FillRect(Rect(x1, 1, x2, SH));
C.Brush.Color := col; // яркая кромка-«легенда» сверху
C.FillRect(Rect(x1, 1, x2, 3));
C.Pen.Style := psSolid; C.Pen.Color := CLR_SEP; C.Pen.Width := 1;
C.MoveTo(x1, 1); C.LineTo(x1, SH);
tw := C.TextWidth(seg.Name);
if (x2 - x1) >= tw + 6 then
begin
cx := x1 + ((x2 - x1) - tw) div 2;
C.Brush.Style := bsClear; C.Font.Color := CLR_LBL;
C.TextOut(cx, ty, seg.Name);
end;
end;
begin
SH := FStripH;
// Фон всей полоски (зона вне транспондера остаётся этим тёмным фоном).
C.Brush.Color := CLR_STRIP_BG; C.Brush.Style := bsSolid; C.Pen.Style := psClear;
C.FillRect(Rect(0, 0, W, SH));
// Высота шрифта привязана к высоте полоски (а не point-size), поэтому текст
// всегда влезает при любом DPI-масштабе Windows. Отриц. Height = glyph-px.
C.Font.Name := 'Courier New'; C.Font.Style := [];
C.Font.Height := -Max(8, Round(SH * 0.5));
th := C.TextHeight('BCN');
// Вертикальный центр под caps: +1 компенсирует пустой descent у заглавных.
ty := (SH - th) div 2 + 1;
if ty < 1 then ty := 1;
// Mode-сегменты, затем маяки (поверх) — одинаковый стиль.
for i := Low(QO100_NB) to High(QO100_NB) do DrawSeg(QO100_NB[i]);
for i := Low(QO100_BEACONS) to High(QO100_BEACONS) do DrawSeg(QO100_BEACONS[i]);
// Верхняя кромка полоски — отделить от спектра.
C.Pen.Style := psSolid; C.Pen.Color := CLR_STRIP_TOP; C.Pen.Width := 1;
C.MoveTo(0, 0); C.LineTo(W, 0);
C.Pen.Style := psSolid;
end;
function TBandPlanOverlay.NeedsRebuild(W: Integer): Boolean;
begin
Result := FCacheDirty or (FCacheW <> W)
or (FKeyW <> W) or (FKeyEnabled <> FEnabled)
or (Abs(FKeyCenter - FCenterFreq) >= 1.0)
or (Abs(FKeySpan - FSpanHz) >= 1.0);
end;
procedure TBandPlanOverlay.RebuildCache(W: Integer);
begin
if W <= 0 then begin FCacheW := 0; FCacheDirty := False; Exit; end;
if (FCache.Width <> W) or (FCache.Height <> FStripH) then
FCache.SetSize(W, FStripH);
DrawSelf(FCache.Canvas, W);
FCacheW := W;
FKeyCenter := FCenterFreq; FKeySpan := FSpanHz; FKeyW := W;
FKeyEnabled := FEnabled;
FCacheDirty := False;
end;
procedure TBandPlanOverlay.CompositeStrip(Target: TBitmap; DstY: Integer);
// Альфа-композит FCache поверх Target в (0, DstY) с постоянной BANDPLAN_ALPHA.
// Один проход по W × STRIP_H пикселей за кадр.
var
X, Y, ty, InvA: Integer;
RS, RD: PByte;
const
A = BANDPLAN_ALPHA;
begin
if (Target = nil) or (FCacheW <= 0) then Exit;
InvA := 255 - A;
Target.BeginUpdate(False);
FCache.BeginUpdate(False);
try
for Y := 0 to FStripH - 1 do
begin
ty := DstY + Y;
if (ty < 0) or (ty >= Target.Height) then Continue;
RS := PByte(FCache.ScanLine[Y]);
RD := PByte(Target.ScanLine[ty]);
if (RS = nil) or (RD = nil) then Continue;
for X := 0 to FCacheW - 1 do
begin
if X >= Target.Width then Break;
{$IFDEF DARWIN}
RD[1] := Byte((A * RS[1] + InvA * RD[1]) div 255);
RD[2] := Byte((A * RS[2] + InvA * RD[2]) div 255);
RD[3] := Byte((A * RS[3] + InvA * RD[3]) div 255);
{$ELSE}
RD[0] := Byte((A * RS[0] + InvA * RD[0]) div 255);
RD[1] := Byte((A * RS[1] + InvA * RD[1]) div 255);
RD[2] := Byte((A * RS[2] + InvA * RD[2]) div 255);
{$ENDIF}
Inc(RS, 4); Inc(RD, 4);
end;
end;
finally
FCache.EndUpdate(False);
Target.EndUpdate(False);
end;
end;
procedure TBandPlanOverlay.DrawOverlay(Target: TBitmap; W, H: Integer);
begin
if (not FEnabled) or (Target = nil) or (W <= 0) or (H <= FStripH) then Exit;
if NeedsRebuild(W) then RebuildCache(W);
CompositeStrip(Target, H - FStripH);
end;
procedure TBandPlanOverlay.DrawOverlayBitmap(Target: TBitmap; W: Integer);
begin
if (Target = nil) or (W <= 0) then Exit;
Target.PixelFormat := pf32bit;
Target.SetSize(W, FStripH);
DrawSelf(Target.Canvas, W);
end;
end.