Files
ewsdr/BandPlanOverlay.pas
ew8bakandClaude Opus 4.8 432c0285f0 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>
2026-06-23 21:02:50 +03:00

311 lines
13 KiB
ObjectPascal
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.