Merge feature/dxcluster-spots: споты DX-кластера на панадаптерах, мода по бэндплану, кнопка DX в RX

This commit is contained in:
2026-08-17 11:29:04 +03:00
16 changed files with 4120 additions and 87 deletions
+14 -1
View File
@@ -54,6 +54,12 @@ type
// Спектр — сигнал и маркеры // Спектр — сигнал и маркеры
SpecLine: TColor; SpecLine: TColor;
SpecFilter: TColor; SpecFilter: TColor;
// Заливка полосы главного фильтра. Отдельно от SpecFilter (тот остался
// подложкой подписей AGC): полоса теперь полупрозрачная, как у слайсов, и
// цвет ей нужен СВОЙ — SpecFilter почти совпадает с нижним цветом фонового
// градиента спектра, поэтому такая заливка читалась не оттенком, а
// затемнением.
SpecFilterBand: TColor;
SpecFilterEdge: TColor; SpecFilterEdge: TColor;
SpecAgcColor: TColor; SpecAgcColor: TColor;
SpecAgcHangColor: TColor; SpecAgcHangColor: TColor;
@@ -158,6 +164,10 @@ begin
Result.SpecGrid := TColor($00685624); Result.SpecGrid := TColor($00685624);
Result.SpecLine := TColor($0040FF80); Result.SpecLine := TColor($0040FF80);
Result.SpecFilter := TColor($006A4E22); Result.SpecFilter := TColor($006A4E22);
// Оттенок полосы — тёплый янтарный, из семейства курсора VFO (Amber): фон
// спектра сине-стальной, поэтому янтарь на нём читается как оттенок, а не
// как «потемнее фона». RGB(255,150,40).
Result.SpecFilterBand := TColor($002896FF);
Result.SpecFilterEdge := TColor($0040FFCC); Result.SpecFilterEdge := TColor($0040FFCC);
Result.SpecAgcColor := TColor($0000AAFF); Result.SpecAgcColor := TColor($0000AAFF);
Result.SpecAgcHangColor := TColor($00FFCC00); Result.SpecAgcHangColor := TColor($00FFCC00);
@@ -266,7 +276,10 @@ begin
Result.SpecLabelText := TColor($00404038); Result.SpecLabelText := TColor($00404038);
Result.SpecGrid := TColor($00B8B4A4); Result.SpecGrid := TColor($00B8B4A4);
Result.SpecLine := TColor($00006400); // тёмно-зелёная линия Result.SpecLine := TColor($00006400); // тёмно-зелёная линия
Result.SpecFilter := TColor($00C8DCC8); // светло-зелёная полоса фильтра Result.SpecFilter := TColor($00C8DCC8); // подложка подписей AGC
// Полоса фильтра на светлой теме: насыщённее фона (тот пергаментный), иначе
// при той же полупрозрачности её не видно. RGB(60,150,60).
Result.SpecFilterBand := TColor($003C963C);
Result.SpecFilterEdge := TColor($00407840); Result.SpecFilterEdge := TColor($00407840);
Result.SpecAgcColor := TColor($00904000); // тёмно-оранжевый AGC Result.SpecAgcColor := TColor($00904000); // тёмно-оранжевый AGC
Result.SpecAgcHangColor := TColor($00802000); Result.SpecAgcHangColor := TColor($00802000);
+1506
View File
File diff suppressed because it is too large Load Diff
+484
View File
@@ -0,0 +1,484 @@
unit DXClusterForm;
{
TDXClusterForm — окно DX-кластера: список принятых спотов, лог соединения и
строка команды в кластер (sh/dx, set/filter — диалект у каждого кластера свой,
поэтому команды не угадываем, а даём отправить руками).
Данные тянутся ПОЛЛИНГОМ: сетевой поток кладёт споты в TDXSpotStore и строки в
лог клиента, а форма раз в секунду сравнивает Version/LogVersion и перечитывает
только при изменении. Никакого Synchronize из потока в UI — та же схема, что у
оверлея спектра.
Двойной клик по споту (или Enter) — QSY: наружу через OnTuneSpot.
}
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Math,
Forms, Controls, Graphics, StdCtrls, ExtCtrls,
FlatButton, FlatEdit, FlatListBox, FlatMemo,
AppTheme, DpiUtils, DXSpotStore, DXClusterClient;
type
// QSY по споту: частота в display-Гц + мода (dxmUnknown — не трогать моду).
TDXTuneEvent = procedure(FreqHz: Double; Mode: TDXMode) of object;
TDXClusterForm = class(TForm)
private
FStore: TDXSpotStore; // не владеет
FClient: TDXClusterClient; // не владеет
FTheme: TAppTheme;
FTitle: TLabel;
FStatus: TLabel;
FBtnConn: TFlatButton;
FBtnClear: TFlatButton;
FBtnSort: TFlatButton;
FBtnClose: TFlatButton;
FList: TFlatListBox;
FLog: TFlatMemo;
FEdCmd: TFlatEdit;
FBtnSend: TFlatButton;
FTimer: TTimer;
FSpots: TDXSpotArray; // снимок, параллельный строкам FList
FSortFreq: Boolean; // False = по времени (свежие сверху)
FLastVer: Int64;
FLastLogVer: Int64;
FOnTune: TDXTuneEvent;
procedure BuildUI;
procedure StyleBtn(B: TFlatButton);
function FormatSpotLine(const S: TDXSpot): string;
procedure RefreshSpots;
procedure RefreshLog;
procedure RefreshStatus;
procedure TuneSelected;
procedure OnTimerTick(Sender: TObject);
procedure OnConnClick(Sender: TObject);
procedure OnClearClick(Sender: TObject);
procedure OnSortClick(Sender: TObject);
procedure OnCloseClick(Sender: TObject);
procedure OnSendClick(Sender: TObject);
procedure OnListDblClick(Sender: TObject);
procedure OnListKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
procedure OnCmdKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
procedure OnFormClose(Sender: TObject; var CloseAction: TCloseAction);
protected
procedure DoShow; override;
procedure DoHide; override;
public
constructor CreateWith(AOwner: TComponent; AStore: TDXSpotStore;
AClient: TDXClusterClient); reintroduce;
procedure ApplyTheme(const T: TAppTheme);
property OnTuneSpot: TDXTuneEvent read FOnTune write FOnTune;
end;
implementation
const
FORM_W = 860;
FORM_H = 560;
MARGIN = 12;
BTN_H = 26;
LOG_H = 130;
// Моноширинный — ТОЛЬКО там, где колонки выровнены пробелами (список спотов и
// лог соединения); всё остальное в окне идёт системным шрифтом, как везде по
// проекту. Имя платформенное, как в CWTerminalForm: 'Courier New' есть не
// всюду (на Linux fontconfig всё равно подставляет свой моноширинный).
{$IFDEF WINDOWS}
MONO_FONT = 'Consolas';
{$ELSE}
MONO_FONT = 'Monospace';
{$ENDIF}
constructor TDXClusterForm.CreateWith(AOwner: TComponent; AStore: TDXSpotStore;
AClient: TDXClusterClient);
var
WorkArea: TRect;
begin
inherited CreateNew(AOwner);
FStore := AStore;
FClient := AClient;
FTheme := DarkTheme;
FSortFreq := False;
FLastVer := -1;
FLastLogVer := -1;
Scaled := False;
Caption := 'DX Cluster';
BorderStyle := bsSizeable;
if AOwner is TCustomForm then
begin
WorkArea := Screen.MonitorFromRect(TCustomForm(AOwner).BoundsRect).WorkareaRect;
Position := poOwnerFormCenter;
end
else
begin
WorkArea := Screen.PrimaryMonitor.WorkareaRect;
Position := poScreenCenter;
end;
Width := Min(DpiScale(FORM_W), WorkArea.Right - WorkArea.Left - DpiScale(48));
Height := Min(DpiScale(FORM_H), WorkArea.Bottom - WorkArea.Top - DpiScale(48));
Constraints.MinWidth := Min(DpiScale(620), Width);
Constraints.MinHeight := Min(DpiScale(380), Height);
OnClose := @OnFormClose;
BuildUI;
ApplyTheme(DarkTheme);
end;
procedure TDXClusterForm.BuildUI;
var
BtnW, Y: Integer;
begin
BtnW := DpiScale(96);
FTitle := TLabel.Create(Self);
FTitle.Parent := Self;
FTitle.SetBounds(DpiScale(MARGIN), DpiScale(10), DpiScale(300), DpiScale(22));
FTitle.Caption := 'DX CLUSTER';
FTitle.Font.Size := 9;
FTitle.Font.Style := [fsBold];
FStatus := TLabel.Create(Self);
FStatus.Parent := Self;
FStatus.SetBounds(DpiScale(MARGIN), DpiScale(31), DpiScale(520), DpiScale(18));
FStatus.Font.Size := 8;
FStatus.Anchors := [akLeft, akTop, akRight];
Y := DpiScale(10);
FBtnClose := TFlatButton.Create(Self);
FBtnClose.Parent := Self;
FBtnClose.SetBounds(ClientWidth - DpiScale(MARGIN) - BtnW, Y, BtnW, DpiScale(BTN_H));
FBtnClose.Caption := 'Close';
FBtnClose.Anchors := [akTop, akRight];
FBtnClose.OnClick := @OnCloseClick;
FBtnSort := TFlatButton.Create(Self);
FBtnSort.Parent := Self;
FBtnSort.SetBounds(FBtnClose.Left - BtnW - DpiScale(6), Y, BtnW, DpiScale(BTN_H));
FBtnSort.Caption := 'BY TIME';
FBtnSort.Anchors := [akTop, akRight];
FBtnSort.OnClick := @OnSortClick;
FBtnClear := TFlatButton.Create(Self);
FBtnClear.Parent := Self;
FBtnClear.SetBounds(FBtnSort.Left - BtnW - DpiScale(6), Y, BtnW, DpiScale(BTN_H));
FBtnClear.Caption := 'CLEAR';
FBtnClear.Anchors := [akTop, akRight];
FBtnClear.OnClick := @OnClearClick;
FBtnConn := TFlatButton.Create(Self);
FBtnConn.Parent := Self;
FBtnConn.SetBounds(FBtnClear.Left - BtnW - DpiScale(6), Y, BtnW, DpiScale(BTN_H));
FBtnConn.Caption := 'CONNECT';
FBtnConn.Anchors := [akTop, akRight];
FBtnConn.OnClick := @OnConnClick;
// Строка команды — внизу, над логом.
FBtnSend := TFlatButton.Create(Self);
FBtnSend.Parent := Self;
FBtnSend.SetBounds(ClientWidth - DpiScale(MARGIN) - BtnW,
ClientHeight - DpiScale(MARGIN + BTN_H), BtnW, DpiScale(BTN_H));
FBtnSend.Caption := 'SEND';
FBtnSend.Anchors := [akRight, akBottom];
FBtnSend.OnClick := @OnSendClick;
FEdCmd := TFlatEdit.Create(Self);
FEdCmd.Parent := Self;
FEdCmd.SetBounds(DpiScale(MARGIN), ClientHeight - DpiScale(MARGIN + BTN_H),
FBtnSend.Left - DpiScale(MARGIN + 6), DpiScale(BTN_H));
FEdCmd.TextHint := 'command to cluster (sh/dx, set/filter …)';
FEdCmd.Anchors := [akLeft, akRight, akBottom];
FEdCmd.OnKeyDown := @OnCmdKeyDown;
FLog := TFlatMemo.Create(Self);
FLog.Parent := Self;
FLog.SetBounds(DpiScale(MARGIN),
FEdCmd.Top - DpiScale(LOG_H + 6),
ClientWidth - DpiScale(MARGIN * 2), DpiScale(LOG_H));
FLog.Anchors := [akLeft, akRight, akBottom];
FLog.ReadOnly := True;
FLog.WordWrap := False;
FLog.Font.Name := MONO_FONT;
FLog.Font.Size := 8;
FList := TFlatListBox.Create(Self);
FList.Parent := Self;
FList.SetBounds(DpiScale(MARGIN), DpiScale(56),
ClientWidth - DpiScale(MARGIN * 2),
FLog.Top - DpiScale(56 + 6));
FList.Anchors := [akLeft, akTop, akRight, akBottom];
FList.Font.Name := MONO_FONT;
FList.Font.Size := 9;
FList.TabStop := True;
FList.OnDblClick := @OnListDblClick;
FList.OnKeyDown := @OnListKeyDown;
FTimer := TTimer.Create(Self);
FTimer.Interval := 1000;
FTimer.Enabled := False;
FTimer.OnTimer := @OnTimerTick;
end;
procedure TDXClusterForm.StyleBtn(B: TFlatButton);
begin
// TFlatButton темы не знает — цвета выставляются вручную, как в ChannelsForm.
if B = nil then Exit;
B.Font.Assign(Font);
B.Font.Size := 8;
B.Font.Style := [];
B.ClrNorm := FTheme.BtnNorm;
B.ClrActive := FTheme.BtnActive;
B.ClrHot := FTheme.BtnHot;
B.ClrBorder := FTheme.BtnBorderNorm;
B.ClrText := FTheme.BtnText;
B.ClrTextAct := FTheme.BtnTextActive;
B.Invalidate;
end;
procedure TDXClusterForm.ApplyTheme(const T: TAppTheme);
begin
FTheme := T;
Color := T.BG;
FTitle.Font.Color := T.Text;
FStatus.Font.Color := T.TextDim;
FList.SetAppTheme(T);
FLog.SetAppTheme(T);
FEdCmd.SetAppTheme(T);
StyleBtn(FBtnConn);
StyleBtn(FBtnClear);
StyleBtn(FBtnSort);
StyleBtn(FBtnClose);
StyleBtn(FBtnSend);
Invalidate;
end;
{ ── Наполнение ───────────────────────────────────────────────────────────── }
function TDXClusterForm.FormatSpotLine(const S: TDXSpot): string;
// Моноширинные колонки: частота | позывной | мода | UTC | спотер | комментарий.
var
FreqStr, Md: string;
begin
FreqStr := FormatFloat('0.0', S.FreqHz / 1000.0); // кГц, как в кластере
while Length(FreqStr) < 10 do FreqStr := ' ' + FreqStr;
// Мода, выведенная из бэндплана, помечена '?': спотер её не называл, и
// видеть разницу полезно — на границах участков таблица может ошибаться.
Md := DXModeName(S.Mode);
if (Md <> '') and S.ModeGuessed then Md := Md + '?';
while Length(Md) < 5 do Md := Md + ' ';
Result := Format('%s %-12s %s %-5s %-10s %s',
[FreqStr, S.Call, Md, S.TimeUTC, S.Spotter, S.Comment]);
end;
procedure TDXClusterForm.RefreshSpots;
var
i, j, Keep: Integer;
T: TDXSpot;
SelCall: string;
SelFreq: Double;
begin
if FStore = nil then Exit;
// Что выделено — запоминаем позывным И частотой: стор намеренно держит один
// позывной на разных диапазонах, и по одному позывному выделение после
// обновления перескочило бы на первый совпавший, а Enter/двойной клик увёл бы
// радио не на тот диапазон.
Keep := FList.ItemIndex;
SelCall := '';
SelFreq := 0;
if (Keep >= 0) and (Keep < Length(FSpots)) then
begin
SelCall := FSpots[Keep].Call;
SelFreq := FSpots[Keep].FreqHz;
end;
FStore.Snapshot(FSpots); // приходит отсортированным по частоте
if not FSortFreq then
// по времени, свежие сверху (вставками — список короткий, TTL его держит)
for i := 1 to High(FSpots) do
begin
T := FSpots[i];
j := i - 1;
while (j >= 0) and (FSpots[j].Stamp < T.Stamp) do
begin
FSpots[j + 1] := FSpots[j];
Dec(j);
end;
FSpots[j + 1] := T;
end;
FList.Items.BeginUpdate;
try
FList.Items.Clear;
for i := 0 to High(FSpots) do
FList.Items.Add(FormatSpotLine(FSpots[i]));
finally
FList.Items.EndUpdate;
end;
// Держим выделение на том же споте, если он ещё в списке. Допуск по частоте —
// тот же, что у дедупа стора: спот того же позывного мог чуть подвинуться.
if SelCall <> '' then
for i := 0 to High(FSpots) do
if SameText(FSpots[i].Call, SelCall) and
(Abs(FSpots[i].FreqHz - SelFreq) <= DX_DEDUP_HZ) then
begin
FList.ItemIndex := i;
Break;
end;
end;
procedure TDXClusterForm.RefreshLog;
var
L: TStringList;
begin
if FClient = nil then Exit;
L := TStringList.Create;
try
FClient.GetLog(L);
FLog.Lines.BeginUpdate;
try
FLog.Lines.Assign(L);
finally
FLog.Lines.EndUpdate;
end;
// прокрутка в конец — свежие строки внизу
FLog.SelStart := Length(FLog.Text);
finally
L.Free;
end;
end;
procedure TDXClusterForm.RefreshStatus;
var
St: TDXClusterState;
S: string;
begin
if FClient = nil then Exit;
St := FClient.State;
S := DXStateName(St);
if FClient.StatusMessage <> '' then S := S + ' — ' + FClient.StatusMessage;
S := S + Format(' | spots stored: %d received this session: %d',
[FStore.Count, FClient.SpotsReceived]);
FStatus.Caption := S;
if FClient.Running then FBtnConn.Caption := 'DISCONNECT'
else FBtnConn.Caption := 'CONNECT';
end;
{ ── События ──────────────────────────────────────────────────────────────── }
procedure TDXClusterForm.OnTimerTick(Sender: TObject);
begin
if (FStore <> nil) and (FStore.Version <> FLastVer) then
begin
FLastVer := FStore.Version;
RefreshSpots;
end;
if (FClient <> nil) and (FClient.LogVersion <> FLastLogVer) then
begin
FLastLogVer := FClient.LogVersion;
RefreshLog;
RefreshStatus;
end;
end;
procedure TDXClusterForm.OnConnClick(Sender: TObject);
begin
if FClient = nil then Exit;
if FClient.Running then FClient.Stop else FClient.Start;
RefreshStatus;
end;
procedure TDXClusterForm.OnClearClick(Sender: TObject);
begin
if FStore <> nil then FStore.Clear;
FLastVer := -1;
RefreshSpots;
end;
procedure TDXClusterForm.OnSortClick(Sender: TObject);
begin
FSortFreq := not FSortFreq;
if FSortFreq then FBtnSort.Caption := 'BY FREQ'
else FBtnSort.Caption := 'BY TIME';
RefreshSpots;
end;
procedure TDXClusterForm.OnCloseClick(Sender: TObject);
begin
Close;
end;
procedure TDXClusterForm.OnSendClick(Sender: TObject);
begin
if (FClient = nil) or (Trim(FEdCmd.Text) = '') then Exit;
FClient.SendCommand(Trim(FEdCmd.Text));
FEdCmd.Text := '';
end;
procedure TDXClusterForm.OnCmdKeyDown(Sender: TObject; var Key: Word;
Shift: TShiftState);
begin
if Key = 13 then
begin
Key := 0;
OnSendClick(nil);
end;
end;
procedure TDXClusterForm.TuneSelected;
var i: Integer;
begin
i := FList.ItemIndex;
if (i < 0) or (i > High(FSpots)) then Exit;
if Assigned(FOnTune) then FOnTune(FSpots[i].FreqHz, FSpots[i].Mode);
end;
procedure TDXClusterForm.OnListDblClick(Sender: TObject);
begin
TuneSelected;
end;
procedure TDXClusterForm.OnListKeyDown(Sender: TObject; var Key: Word;
Shift: TShiftState);
// Enter по выделенной строке = то же, что двойной клик (QSY на спот).
begin
if (Key = 13) or (Key = 10) then
begin
Key := 0;
TuneSelected;
end;
end;
procedure TDXClusterForm.OnFormClose(Sender: TObject; var CloseAction: TCloseAction);
begin
CloseAction := caHide;
end;
procedure TDXClusterForm.DoShow;
begin
inherited DoShow;
// Поллинг живёт только пока окно видимо (как анализатор лупы маяка).
FLastVer := -1;
FLastLogVer := -1;
RefreshSpots;
RefreshLog;
RefreshStatus;
FTimer.Enabled := True;
end;
procedure TDXClusterForm.DoHide;
begin
FTimer.Enabled := False;
inherited DoHide;
end;
end.
+580
View File
@@ -0,0 +1,580 @@
unit DXSpotOverlay;
{
Оверлей DX-спотов на спектре: позывной в «чипе» + вертикальный штрих к своей
частоте. Пересекающиеся подписи раскладываются лесенкой по рядам сверху вниз.
Производительность — ровно модель BandPlanOverlay/VfoOverlay, ничего нового:
• Кэшируется ТОЛЬКО полоса подписей (W × FBandH, вверху спектра) в key-color
битмап; пересборка идёт исключительно по dirty-ключу (версия стора / вид /
ширина / тема / фильтры / тик затухания), а каждый кадр — один композит
W × FBandH с постоянной альфой (BlendBitmapKey, тот же дворд-блендинг).
Полноэкранный кэш W × H на кадр стоил бы в разы дороже — поэтому штрихи
ниже полосы в кэш НЕ входят.
• Версия стора глобальна, а окно у каждого пана своё, поэтому на смену
версии сначала берётся снимок окна и сверяется его ПОДПИСЬ: спот, севший
на чужой диапазон, до полосы подписей этого пана не доходит. Без этого
каждый пан пересобирал бы кэш (и перезаливал GL-текстуру) на любой спот
откуда угодно — цена, которая множится на число панов.
• Штрихи от полосы до низа спектра рисует вызывающая сторона теми же
примитивами, что и все прочие маркеры: CPU — RawVLine внутри RawBegin/
RawEnd (никакого Canvas на битмапе кадра), GL — DrawLine. Оверлей отдаёт
только готовый список X/цвет (TickCount/Tick), посчитанный при пересборке
кэша, — на кадр никакой арифметики по спотам.
• GL-путь берёт ТОТ ЖЕ битмап текстурой (DrawOverlayBitmap + UploadBitmap с
color-key), заливка — только при dirty. Вид CPU и GL совпадает.
Домен частот — display-Гц, как их видит спектр (GetViewWindow). Частота спота
кладётся как есть: на QO-100 кластеры постят downlink 10489.xxx, что совпадает
со шкалой панадаптера само собой.
}
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Graphics, Types, Math, Forms,
VfoOverlay, // BlendBitmapKey — общий keyed-композит (одна копия на проект)
DXSpotStore;
const
DXSPOT_ALPHA = 225; // альфа композита полосы подписей
DXSPOT_ROW_BASE_H = 15; // базовая высота ряда @96dpi
DXSPOT_MAX_ROWS = 6; // потолок рядов лесенки (настройка кладётся сюда)
type
// Один разложенный спот: где чип, где штрих, каким цветом.
TDXSpotItem = record
X: Integer; // X штриха (пиксель частоты)
Chip: TRect; // прямоугольник подписи (для хит-теста)
Color: TColor; // цвет с учётом возраста/своего позывного
Spot: TDXSpot;
end;
TDXSpotOverlay = class(TComponent)
private
FStore: TDXSpotStore; // не владеет
FCenterFreq: Double;
FSpanHz: Double;
FEnabled: Boolean;
FLightTheme: Boolean;
FOwnCall: string;
// фильтры
FModes: TDXModeSet;
FMaxAgeMin: Integer;
FMaxRows: Integer;
FCache: TBitmap;
FCacheW: Integer;
FCacheDirty: Boolean;
FBandH: Integer; // высота полосы подписей (px, масштаб по DPI)
FRowH: Integer;
// ключ кэша
FKeyCenter: Double;
FKeySpan: Double;
FKeyW: Integer;
FKeyEnabled: Boolean;
FKeyVersion: Int64;
FKeyLight: Boolean;
FKeyRows: Integer;
FKeyAgeTick: Int64;
FKeyStamp: Int64; // подпись набора, по которому собран кэш
FAgeTick: Int64; // бампается таймером UI — обновить затухание
FRenderVer: Int64; // ++ на каждую пересборку кэша — ключ GL-текстуры
FLayoutTTL: Integer; // горизонт затухания, снят один раз на раскладку
// Снимок окна: берётся при смене вида/версии стора, живёт до пересборки —
// подпись считается по нему же, второй раз стор не дёргаем.
FArr: TDXSpotArray;
FArrN: Integer;
FArrStamp: Int64;
FItems: array of TDXSpotItem;
FItemCount: Integer;
FHidden: Integer; // сколько спотов не влезло в лесенку
function FreqToX(FreqHz: Double; W: Integer): Integer;
function AgeColor(const S: TDXSpot): TColor;
procedure TakeSnapshot;
procedure Layout(C: TCanvas; W: Integer);
procedure DrawSelf(C: TCanvas; W: Integer);
procedure RebuildCache(W: Integer; Ver: Int64);
function ViewKeysChanged(W: Integer): Boolean;
procedure SetMaxRows(V: Integer);
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
procedure Attach(AStore: TDXSpotStore);
procedure SetView(ACenterHz, ASpanHz: Double);
procedure SetEnabled(En: Boolean);
procedure SetLightTheme(Light: Boolean);
procedure SetOwnCall(const ACall: string);
procedure SetFilters(AModes: TDXModeSet; AMaxAgeMin: Integer);
// Тик затухания: дёргается редко (раз в десятки секунд) — только чтобы
// старые споты потускнели и ушедшие по TTL исчезли.
procedure TickAge;
procedure Invalidate;
function Active: Boolean;
// Пересобрать кэш+раскладку, если протух ключ. Вызывается В НАЧАЛЕ кадра:
// штрихи рисуются РАНЬШЕ композита полосы, а список штрихов рождается
// именно при пересборке — иначе первый кадр после нового спота рисовал бы
// старую раскладку.
procedure EnsureRendered(W: Integer);
// CPU-путь: композит полосы подписей вверху Target.
procedure DrawOverlay(Target: TBitmap; W, H: Integer);
// GL-путь: полоса подписей в Target-битмап (W × BandHeight) под текстуру.
procedure DrawOverlayBitmap(Target: TBitmap; W: Integer);
// Штрихи ниже полосы — рисует вызывающая сторона (RawVLine / GL DrawLine).
function TickCount: Integer;
procedure Tick(Idx: Integer; out X: Integer; out Color: TColor);
// Хит-тест по подписи (клик = настроиться на спот).
function SpotAtPixel(X, Y: Integer; out Spot: TDXSpot): Boolean;
property BandHeight: Integer read FBandH;
property CenterFreq: Double read FCenterFreq; // для GL-инвалидизации
property SpanHz: Double read FSpanHz;
property MaxRows: Integer read FMaxRows write SetMaxRows;
property Hidden: Integer read FHidden;
property CacheDirty: Boolean read FCacheDirty;
// Меняется ровно тогда, когда картинка полосы стала другой. GL-путь по нему
// решает, перезаливать ли текстуру: он не зависит от того, кто первым
// дёрнул пересборку в этом кадре (штрихи или композит).
property RenderVersion: Int64 read FRenderVer;
end;
implementation
const
KEY_COLOR = TColor($00FF00FF); // тот же ключ прозрачности, что у VfoOverlay
// Палитра (TColor = $00BBGGRR). Своя пара на тему: на тёмном спектре чип
// тёмный со светлой подписью, на светлом — наоборот, иначе подписи спотов
// на светлой теме сливались бы в грязное пятно.
// тёмная тема светлая тема
CLR_CHIP_BG: array[Boolean] of TColor = ($00202018, $00F2F2EC);
CLR_CHIP_TEXT: array[Boolean] of TColor = ($00F0F0F0, $00181818);
CLR_FRESH: array[Boolean] of TColor = ($00E0C040, $00A05800); // свежий
CLR_STALE: array[Boolean] of TColor = ($00706850, $00B0A898); // на исходе TTL
CLR_OWN: array[Boolean] of TColor = ($0040D0FF, $000060C8); // свой позывной
CHIP_PAD_X = 3;
CHIP_GAP = 3; // минимальный зазор между чипами в одном ряду
// Подпись видимого набора (FNV-1a 64). Базис — канонический $CBF29CE4…,
// сложенный из половин: цельным литералом он не влезает в Int64, а
// сравниваем мы подпись только сама с собой, так что важна лишь стабильность.
FNV_BASIS_HI = QWord($CBF29CE4);
FNV_BASIS_LO = QWord($84222325);
FNV_PRIME: QWord = $00000100000001B3;
function Mix(A, B: TColor; Num, Den: Integer): TColor;
// A→B на Num/Den (0 = A, Den = B).
var ra, ga, ba, rb, gb, bb: Integer;
begin
if Den <= 0 then Exit(A);
ra := A and $FF; ga := (A shr 8) and $FF; ba := (A shr 16) and $FF;
rb := B and $FF; gb := (B shr 8) and $FF; bb := (B shr 16) and $FF;
ra := ra + (rb - ra) * Num div Den;
ga := ga + (gb - ga) * Num div Den;
ba := ba + (bb - ba) * Num div Den;
Result := TColor((ba shl 16) or (ga shl 8) or ra);
end;
{ ── Создание / параметры ─────────────────────────────────────────────────── }
constructor TDXSpotOverlay.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
FRowH := Round(DXSPOT_ROW_BASE_H * Screen.PixelsPerInch / 96);
if FRowH < DXSPOT_ROW_BASE_H then FRowH := DXSPOT_ROW_BASE_H;
FMaxRows := 4;
FBandH := FRowH * FMaxRows + 4;
FCenterFreq := 0;
FSpanHz := 0;
FEnabled := False;
FModes := [];
FMaxAgeMin := 0;
FCache := TBitmap.Create;
FCache.PixelFormat := pf32bit;
FCacheW := 0;
FCacheDirty := True;
FKeyCenter := -1; FKeySpan := -1; FKeyW := -1; FKeyVersion := -1;
FKeyEnabled := False; FKeyLight := False; FKeyRows := -1; FKeyAgeTick := -1;
FKeyStamp := 0;
FArrN := 0;
FArrStamp := 0;
FAgeTick := 0;
FRenderVer := 0;
end;
destructor TDXSpotOverlay.Destroy;
begin
FCache.Free;
inherited Destroy;
end;
procedure TDXSpotOverlay.Attach(AStore: TDXSpotStore);
begin
FStore := AStore;
FCacheDirty := True;
end;
procedure TDXSpotOverlay.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 TDXSpotOverlay.SetEnabled(En: Boolean);
begin
if FEnabled = En then Exit;
FEnabled := En;
FCacheDirty := True;
end;
procedure TDXSpotOverlay.SetLightTheme(Light: Boolean);
begin
if FLightTheme = Light then Exit;
FLightTheme := Light;
FCacheDirty := True;
end;
procedure TDXSpotOverlay.SetOwnCall(const ACall: string);
begin
if SameText(FOwnCall, ACall) then Exit;
FOwnCall := UpperCase(Trim(ACall));
FCacheDirty := True;
end;
procedure TDXSpotOverlay.SetFilters(AModes: TDXModeSet; AMaxAgeMin: Integer);
begin
if (FModes = AModes) and (FMaxAgeMin = AMaxAgeMin) then Exit;
FModes := AModes;
FMaxAgeMin := AMaxAgeMin;
FCacheDirty := True;
end;
procedure TDXSpotOverlay.SetMaxRows(V: Integer);
begin
V := EnsureRange(V, 1, DXSPOT_MAX_ROWS);
if V = FMaxRows then Exit;
FMaxRows := V;
FBandH := FRowH * FMaxRows + 4;
FCacheDirty := True;
end;
procedure TDXSpotOverlay.TickAge;
begin
Inc(FAgeTick);
end;
procedure TDXSpotOverlay.Invalidate;
begin
FCacheDirty := True;
end;
function TDXSpotOverlay.Active: Boolean;
begin
Result := FEnabled and (FStore <> nil);
end;
function TDXSpotOverlay.FreqToX(FreqHz: Double; W: Integer): Integer;
begin
if FSpanHz <= 0 then Result := -1
else Result := Round((FreqHz - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W);
end;
function TDXSpotOverlay.AgeColor(const S: TDXSpot): TColor;
// Свежий → CLR_FRESH, к концу окна TTL плавно уходит в CLR_STALE. Свой
// позывной выделен цветом (и тоже гаснет — иначе старый спот кричал бы
// громче свежих). TTL берётся из FLayoutTTL: он посчитан один раз на
// раскладку, чтобы не дёргать лок стора на каждый спот.
var
AgeMin, K: Integer;
Base: TColor;
begin
Base := CLR_FRESH[FLightTheme];
if (FOwnCall <> '') and SameText(S.Call, FOwnCall) then
Base := CLR_OWN[FLightTheme];
if FLayoutTTL <= 0 then Exit(Base);
AgeMin := Round((Now - S.Stamp) * 24 * 60);
K := EnsureRange(AgeMin * 100 div Max(FLayoutTTL, 1), 0, 100);
Result := Mix(Base, CLR_STALE[FLightTheme], K, 100);
end;
{ ── Раскладка лесенки ────────────────────────────────────────────────────── }
procedure TDXSpotOverlay.TakeSnapshot;
// Снимок видимого окна + его подпись. Подпись складывает всё, от чего зависит
// картинка полосы: позывной, частоту, моду и метку времени (она правит цвет
// затухания и обновляется при пере-споте). Совпала с прошлой — пересобирать
// нечего, какой бы ни стала версия стора.
var
i, k: Integer;
H: QWord;
LoHz, HiHz: Double;
begin
FArrN := 0;
SetLength(FArr, 0);
FArrStamp := 0;
if (FStore = nil) or (FSpanHz <= 0) then Exit;
// Горизонт затухания: явный фильтр по возрасту, иначе TTL стора (чтение
// под локом — поэтому один раз на снимок, а не на каждый спот).
FLayoutTTL := FMaxAgeMin;
if FLayoutTTL <= 0 then FLayoutTTL := FStore.TTLMinutes;
LoHz := FCenterFreq - FSpanHz / 2;
HiHz := FCenterFreq + FSpanHz / 2;
FArrN := FStore.SnapshotRange(LoHz, HiHz, FModes, FMaxAgeMin, FArr);
// Хэш живёт переполнением — проверки снимаем локально, чтобы сборка с
// {$Q+}/{$R+} (у нас их нет, но режимы правятся в .lpi) не падала на нём.
{$PUSH}{$Q-}{$R-}
H := (FNV_BASIS_HI shl 32) or FNV_BASIS_LO; // FNV-1a 64
for i := 0 to FArrN - 1 do
begin
for k := 1 to Length(FArr[i].Call) do
H := (H xor QWord(Ord(FArr[i].Call[k]))) * FNV_PRIME;
H := (H xor QWord(Round(FArr[i].FreqHz))) * FNV_PRIME;
H := (H xor QWord(Round(FArr[i].Stamp * 86400))) * FNV_PRIME;
H := (H xor QWord(Ord(FArr[i].Mode))) * FNV_PRIME;
end;
FArrStamp := Int64(H);
{$POP}
end;
procedure TDXSpotOverlay.Layout(C: TCanvas; W: Integer);
// Споты приходят из стора отсортированными по частоте; каждому даём ПЕРВЫЙ
// ряд сверху, где подпись не наезжает на предыдущую. Не нашлось ряда —
// спот скрыт (считаем в FHidden), в лесенке дырок не оставляем. Работаем по
// снимку FArr — его взял TakeSnapshot перед пересборкой.
var
N, i, r, TW, ChipW, ChipX, Row: Integer;
RowRight: array[0..DXSPOT_MAX_ROWS-1] of Integer;
begin
FItemCount := 0;
FHidden := 0;
if (FStore = nil) or (FSpanHz <= 0) or (W <= 0) then Exit;
N := FArrN;
if N = 0 then Exit;
for r := 0 to FMaxRows - 1 do RowRight[r] := -CHIP_GAP - 1;
SetLength(FItems, N);
for i := 0 to N - 1 do
begin
TW := C.TextWidth(FArr[i].Call);
ChipW := TW + CHIP_PAD_X * 2;
ChipX := FreqToX(FArr[i].FreqHz, W) - ChipW div 2;
if ChipX < 0 then ChipX := 0;
if ChipX + ChipW > W then ChipX := W - ChipW;
if ChipX < 0 then Continue; // подпись шире окна — не рисуем
Row := -1;
for r := 0 to FMaxRows - 1 do
if ChipX > RowRight[r] + CHIP_GAP then
begin
Row := r;
Break;
end;
if Row < 0 then
begin
Inc(FHidden);
Continue;
end;
RowRight[Row] := ChipX + ChipW;
FItems[FItemCount].X := FreqToX(FArr[i].FreqHz, W);
FItems[FItemCount].Chip := Rect(ChipX, Row * FRowH + 2,
ChipX + ChipW, Row * FRowH + FRowH);
FItems[FItemCount].Color := AgeColor(FArr[i]);
FItems[FItemCount].Spot := FArr[i];
Inc(FItemCount);
end;
SetLength(FItems, FItemCount);
end;
procedure TDXSpotOverlay.DrawSelf(C: TCanvas; W: Integer);
// Рендер полосы подписей в канвас W × FBandH. Фон — KEY_COLOR (прозрачный
// при композите и в GL-текстуре).
var
i: Integer;
R: TRect;
Col: TColor;
begin
C.Brush.Color := KEY_COLOR;
C.Brush.Style := bsSolid;
C.Pen.Style := psClear;
C.FillRect(Rect(0, 0, W, FBandH));
// Гарнитуру НЕ задаём — системная, как договорено по проекту: позывные тут
// не в колонках, ширина чипа и так меряется по TextWidth. Задан только
// размер, привязанный к высоте ряда (а не к point-size), — подпись влезает
// при любом DPI-масштабе, как в бэндплане.
C.Font.Style := [fsBold];
C.Font.Height := -Max(8, Round(FRowH * 0.62));
Layout(C, W);
for i := 0 to FItemCount - 1 do
begin
R := FItems[i].Chip;
Col := FItems[i].Color;
// Штрих от низа чипа до низа полосы (дальше его продолжает вызывающая
// сторона по TickCount/Tick — уже прямо в кадре).
if (FItems[i].X >= 0) and (FItems[i].X < W) then
begin
C.Pen.Style := psSolid; C.Pen.Color := Col; C.Pen.Width := 1;
C.MoveTo(FItems[i].X, R.Bottom);
C.LineTo(FItems[i].X, FBandH);
end;
// Чип: тёмная подложка + кромка цветом спота + позывной.
C.Brush.Color := CLR_CHIP_BG[FLightTheme]; C.Brush.Style := bsSolid;
C.Pen.Style := psSolid; C.Pen.Color := Col; C.Pen.Width := 1;
C.Rectangle(R.Left, R.Top, R.Right, R.Bottom);
C.Brush.Style := bsClear;
C.Font.Color := CLR_CHIP_TEXT[FLightTheme];
C.TextOut(R.Left + CHIP_PAD_X,
R.Top + (R.Bottom - R.Top - C.TextHeight('W')) div 2,
FItems[i].Spot.Call);
end;
C.Pen.Style := psSolid;
end;
{ ── Кэш ──────────────────────────────────────────────────────────────────── }
function TDXSpotOverlay.ViewKeysChanged(W: Integer): Boolean;
// Всё, что делает картинку заведомо другой, КРОМЕ версии стора: её разбирает
// EnsureRendered отдельно (версия глобальна, окно — наше).
var HalfPxHz: Double;
begin
if FStore = nil then Exit(False);
// Порог по центру/спану — полпикселя (как в бэндплане): суб-пиксельный
// дрейф центра при QO-100 decoder-lock не должен гонять полную пересборку.
HalfPxHz := 0.5 * FSpanHz / Max(W, 1);
if HalfPxHz < 1.0 then HalfPxHz := 1.0;
Result := FCacheDirty or (FCacheW <> W) or (FKeyW <> W)
or (FKeyEnabled <> FEnabled)
or (FKeyLight <> FLightTheme)
or (FKeyRows <> FMaxRows)
or (FKeyAgeTick <> FAgeTick)
or (Abs(FKeyCenter - FCenterFreq) >= HalfPxHz)
or (Abs(FKeySpan - FSpanHz) >= HalfPxHz);
end;
procedure TDXSpotOverlay.RebuildCache(W: Integer; Ver: Int64);
// Снимок (TakeSnapshot) к этому моменту уже взят вызывающим — здесь только
// рисование и запись ключей.
begin
if (W <= 0) or (FStore = nil) then
begin
FCacheW := 0; FCacheDirty := False; FItemCount := 0;
Inc(FRenderVer);
Exit;
end;
if (FCache.Width <> W) or (FCache.Height <> FBandH) then
FCache.SetSize(W, FBandH);
DrawSelf(FCache.Canvas, W);
Inc(FRenderVer);
FCacheW := W;
FKeyCenter := FCenterFreq;
FKeySpan := FSpanHz;
FKeyW := W;
FKeyEnabled := FEnabled;
FKeyVersion := Ver;
FKeyStamp := FArrStamp;
FKeyLight := FLightTheme;
FKeyRows := FMaxRows;
FKeyAgeTick := FAgeTick;
FCacheDirty := False;
end;
{ ── Рисование ────────────────────────────────────────────────────────────── }
procedure TDXSpotOverlay.EnsureRendered(W: Integer);
var Ver: Int64;
begin
if (not Active) or (W <= 0) then Exit;
Ver := FStore.Version;
if ViewKeysChanged(W) then
begin
TakeSnapshot;
RebuildCache(W, Ver);
Exit;
end;
if FKeyVersion = Ver then Exit; // в сторе с прошлого кадра ничего
// Стор правили — но, может, не в нашем окне: снимок с подписью дешевле
// пересборки полосы (и, в GL, перезаливки текстуры). Версию запоминаем в
// любом случае: этот вопрос уже разобран.
FKeyVersion := Ver;
TakeSnapshot;
if FArrStamp = FKeyStamp then Exit;
RebuildCache(W, Ver);
end;
procedure TDXSpotOverlay.DrawOverlay(Target: TBitmap; W, H: Integer);
begin
if (not Active) or (Target = nil) or (W <= 0) or (H <= FBandH) then Exit;
EnsureRendered(W);
if FCacheW <= 0 then Exit;
BlendBitmapKey(Target, FCache, 0, 0, DXSPOT_ALPHA);
end;
procedure TDXSpotOverlay.DrawOverlayBitmap(Target: TBitmap; W: Integer);
begin
if (Target = nil) or (W <= 0) then Exit;
// Раскладка/список штрихов должны быть свежими и в GL-пути.
EnsureRendered(W);
Target.PixelFormat := pf32bit;
Target.SetSize(W, FBandH);
Target.Canvas.Draw(0, 0, FCache);
end;
function TDXSpotOverlay.TickCount: Integer;
begin
if not Active then Result := 0 else Result := FItemCount;
end;
procedure TDXSpotOverlay.Tick(Idx: Integer; out X: Integer; out Color: TColor);
begin
if (Idx < 0) or (Idx >= FItemCount) then
begin
X := -1; Color := clNone;
Exit;
end;
X := FItems[Idx].X;
Color := FItems[Idx].Color;
end;
function TDXSpotOverlay.SpotAtPixel(X, Y: Integer; out Spot: TDXSpot): Boolean;
// Хит только по полосе подписей: ниже неё живут тюнинг/маркеры/слайсы, и
// перехватывать там клик было бы поперёк привычного поведения спектра.
var i: Integer;
begin
Result := False;
if (not Active) or (Y < 0) or (Y > FBandH) then Exit;
for i := 0 to FItemCount - 1 do
if (X >= FItems[i].Chip.Left - 2) and (X <= FItems[i].Chip.Right + 2) and
(Y >= FItems[i].Chip.Top - 2) and (Y <= FItems[i].Chip.Bottom + 2) then
begin
Spot := FItems[i].Spot;
Exit(True);
end;
end;
end.
+509
View File
@@ -0,0 +1,509 @@
unit DXSpotStore;
{
DXSpotStore.pas — потокобезопасное хранилище DX-спотов.
Единственная точка обмена между сетевым потоком кластера (TDXClusterClient,
пишет) и UI (оверлей спектра + окно списка, читают). Поэтому здесь НЕТ ни
Synchronize, ни очередей в главный поток: поток кластера кладёт спот прямо
сюда под критической секцией, а UI замечает изменение по монотонному
счётчику Version — ровно так же, как оверлеи замечают смену вида по своим
ключам кэша. Никаких LCL-зависимостей (юнит компилируется и в headless).
Дедуп: спот с тем же позывным в пределах DEDUP_HZ считается тем же самым —
обновляем частоту/время/комментарий, а не плодим строки. Тот же позывной на
другом диапазоне — отдельная запись.
TTL: споты старше FTTLMinutes выбрасываются при каждой правке/снимке.
}
{$IFDEF FPC}
{$MODE Delphi}
{$LONGSTRINGS ON}
{$ENDIF}
interface
uses
SysUtils, Classes, SyncObjs, DateUtils, StrUtils; // StrUtils — PosEx
const
DX_DEDUP_HZ = 5000.0; // тот же позывной в этом окне = тот же спот
DX_DEFAULT_TTL = 30; // мин
DX_DEFAULT_MAX = 500; // потолок записей
type
// Мода спота. Определяется по комментарию кластера (FT8/CW/…), при неудаче —
// остаётся dxmUnknown: гадать по частоте не пытаемся, бэндпланы разные.
TDXMode = (dxmUnknown, dxmCW, dxmSSB, dxmDigi, dxmFT8, dxmFT4, dxmRTTY,
dxmPSK, dxmFM, dxmSSTV);
TDXModeSet = set of TDXMode;
TDXSpot = record
FreqHz: Double; // частота DX-станции, Гц (кластер отдаёт кГц)
Call: string; // позывной DX
Spotter: string; // кто заспотил
Comment: string; // комментарий кластера (уже без времени)
TimeUTC: string; // 'HHMM' как пришло от кластера
Stamp: TDateTime; // локальное время приёма — для TTL и затухания
Mode: TDXMode;
ModeGuessed: Boolean; // мода не из комментария, а из бэндплана (см. ниже)
end;
TDXSpotArray = array of TDXSpot;
TDXSpotStore = class
private
FLock: TCriticalSection;
FSpots: TDXSpotArray;
FCount: Integer;
FVersion: Int64;
FTTLMinutes: Integer;
FMaxSpots: Integer;
procedure PurgeLocked;
procedure DropOldestLocked;
function GetTTLMinutes: Integer;
procedure SetTTLMinutes(V: Integer);
function GetMaxSpots: Integer;
procedure SetMaxSpots(V: Integer);
public
constructor Create;
destructor Destroy; override;
// Добавить/обновить спот (вызывается из потока кластера).
procedure Add(const S: TDXSpot);
procedure Clear;
// Выбросить просроченное прямо сейчас. Нужен тому, кто следит за временем
// снаружи: сам стор чистится только на Add и снимках, а когда споты никто
// не берёт и не приходит новых, устаревать им иначе негде.
procedure Purge;
// Снимок всех спотов, отсортированный по частоте.
function Snapshot(out Arr: TDXSpotArray): Integer;
// Снимок окна [LoHz..HiHz] с фильтром по моде (Modes = [] — без фильтра)
// и по возрасту (MaxAgeMin <= 0 — только общий TTL). Для оверлея.
function SnapshotRange(LoHz, HiHz: Double; Modes: TDXModeSet;
MaxAgeMin: Integer; out Arr: TDXSpotArray): Integer;
// Монотонный счётчик правок — ключ инвалидации кэша оверлея/списка.
function Version: Int64;
function Count: Integer;
// Читаются сетевым потоком (PurgeLocked/Add), пишутся из UI — только через
// критическую секцию, как и всё остальное состояние стора.
property TTLMinutes: Integer read GetTTLMinutes write SetTTLMinutes;
property MaxSpots: Integer read GetMaxSpots write SetMaxSpots;
end;
// Мода по комментарию кластера ('FT8 -12 dB' → dxmFT8). Пусто/непонятно —
// dxmUnknown.
function DXModeFromComment(const Comment: string): TDXMode;
// Мода по участку бэндплана — запасной путь, когда комментарий молчит (в CW и
// SSB его сплошь и рядом просто не пишут). dxmUnknown = участок спорный или
// вне таблицы; см. DX_BAND_PLAN о том, где мы намеренно не гадаем.
function DXModeFromFreq(FreqHz: Double): TDXMode;
function DXModeName(M: TDXMode): string;
implementation
const
MODE_NAMES: array[TDXMode] of string =
('', 'CW', 'SSB', 'DIGI', 'FT8', 'FT4', 'RTTY', 'PSK', 'FM', 'SSTV');
function DXModeName(M: TDXMode): string;
begin
Result := MODE_NAMES[M];
end;
function DXModeFromComment(const Comment: string): TDXMode;
// Ищем маркер моды как ОТДЕЛЬНОЕ слово: 'CW' внутри 'CWOPS' или позывного
// модой не является. Порядок проверки — от длинных/специфичных к общим.
var
U: string;
function HasWord(const W: string): Boolean;
var P, L, N: Integer; OkL, OkR: Boolean;
begin
Result := False;
L := Length(W); N := Length(U);
P := Pos(W, U);
while P > 0 do
begin
OkL := (P = 1) or not (U[P-1] in ['A'..'Z', '0'..'9', '/', '-']);
OkR := (P + L > N) or not (U[P+L] in ['A'..'Z', '0'..'9', '/', '-']);
if OkL and OkR then Exit(True);
P := PosEx(W, U, P + 1);
end;
end;
begin
Result := dxmUnknown;
if Comment = '' then Exit;
U := UpperCase(Comment);
if HasWord('FT8') then Result := dxmFT8
else if HasWord('FT4') then Result := dxmFT4
else if HasWord('RTTY') then Result := dxmRTTY
else if HasWord('PSK') or HasWord('PSK31') or HasWord('BPSK') then Result := dxmPSK
else if HasWord('SSTV') then Result := dxmSSTV
else if HasWord('CW') then Result := dxmCW
else if HasWord('SSB') or HasWord('USB') or HasWord('LSB') then Result := dxmSSB
else if HasWord('FM') then Result := dxmFM
else if HasWord('JT65') or HasWord('JT9') or HasWord('JS8') or
HasWord('MSK144') or HasWord('OLIVIA') or HasWord('DIGI') then
Result := dxmDigi;
end;
{ ── Мода по бэндплану ────────────────────────────────────────────────────── }
type
TDXBandSeg = record
LoKHz, HiKHz: Double; // [Lo; Hi)
Mode: TDXMode;
end;
const
// Участки бэндплана для запасного определения моды. Читается СВЕРХУ ВНИЗ,
// первое попадание выигрывает — поэтому узкие «водопои» цифры стоят раньше
// широких сегментов, внутрь которых они попадают (FT8 на 7074 живёт посреди
// телефонного участка R1, FT4 на 21140 — посреди 15-метрового).
//
// ★ Границы — по плану IARU Region 1 (наш регион): у R2/R3 они другие, и
// спорные куски мы НАМЕРЕННО оставляем пустыми, а не гадаем — пропуск честнее
// ошибки. Отсюда дырки: 1843-1850 (R1 SSB против R2 CW), 7053-7125 у R2 —
// данные, а не телефон, и т.п. Мода из комментария всегда важнее этой
// таблицы, сюда попадают только споты, где комментарий промолчал.
DX_BAND_PLAN: array[0..47] of TDXBandSeg = (
// ── узкие окна FT8/FT4 (перекрывают широкие сегменты ниже) ──
(LoKHz: 1840.0; HiKHz: 1843.0; Mode: dxmFT8),
(LoKHz: 3573.0; HiKHz: 3576.0; Mode: dxmFT8),
(LoKHz: 7047.0; HiKHz: 7049.0; Mode: dxmFT4),
(LoKHz: 7074.0; HiKHz: 7078.0; Mode: dxmFT8),
(LoKHz: 10136.0; HiKHz: 10139.0; Mode: dxmFT8),
(LoKHz: 10140.0; HiKHz: 10142.0; Mode: dxmFT4),
(LoKHz: 14074.0; HiKHz: 14078.0; Mode: dxmFT8),
(LoKHz: 14080.0; HiKHz: 14082.0; Mode: dxmFT4),
(LoKHz: 18100.0; HiKHz: 18102.0; Mode: dxmFT8),
(LoKHz: 18104.0; HiKHz: 18106.0; Mode: dxmFT4),
(LoKHz: 21074.0; HiKHz: 21078.0; Mode: dxmFT8),
(LoKHz: 21140.0; HiKHz: 21142.0; Mode: dxmFT4),
(LoKHz: 24915.0; HiKHz: 24917.0; Mode: dxmFT8),
(LoKHz: 24919.0; HiKHz: 24921.0; Mode: dxmFT4),
(LoKHz: 28074.0; HiKHz: 28078.0; Mode: dxmFT8),
(LoKHz: 28180.0; HiKHz: 28182.0; Mode: dxmFT4),
(LoKHz: 50313.0; HiKHz: 50323.0; Mode: dxmFT8),
(LoKHz: 144174.0; HiKHz: 144180.0; Mode: dxmFT8),
(LoKHz: 432174.0; HiKHz: 432180.0; Mode: dxmFT8),
// ── КВ, широкие сегменты ──
(LoKHz: 1800.0; HiKHz: 1838.0; Mode: dxmCW),
(LoKHz: 1838.0; HiKHz: 1843.0; Mode: dxmDigi),
(LoKHz: 1850.0; HiKHz: 2000.0; Mode: dxmSSB),
(LoKHz: 3500.0; HiKHz: 3570.0; Mode: dxmCW),
(LoKHz: 3570.0; HiKHz: 3600.0; Mode: dxmDigi),
(LoKHz: 3600.0; HiKHz: 4000.0; Mode: dxmSSB),
(LoKHz: 7000.0; HiKHz: 7040.0; Mode: dxmCW),
(LoKHz: 7040.0; HiKHz: 7053.0; Mode: dxmDigi),
(LoKHz: 7053.0; HiKHz: 7300.0; Mode: dxmSSB),
(LoKHz: 10100.0; HiKHz: 10130.0; Mode: dxmCW),
(LoKHz: 10130.0; HiKHz: 10150.0; Mode: dxmDigi),
(LoKHz: 14000.0; HiKHz: 14070.0; Mode: dxmCW),
(LoKHz: 14070.0; HiKHz: 14099.0; Mode: dxmDigi),
(LoKHz: 14101.0; HiKHz: 14350.0; Mode: dxmSSB),
(LoKHz: 18068.0; HiKHz: 18095.0; Mode: dxmCW),
(LoKHz: 18095.0; HiKHz: 18109.0; Mode: dxmDigi),
(LoKHz: 18111.0; HiKHz: 18168.0; Mode: dxmSSB),
(LoKHz: 21000.0; HiKHz: 21070.0; Mode: dxmCW),
(LoKHz: 21070.0; HiKHz: 21120.0; Mode: dxmDigi),
(LoKHz: 21151.0; HiKHz: 21450.0; Mode: dxmSSB),
(LoKHz: 24890.0; HiKHz: 24915.0; Mode: dxmCW),
(LoKHz: 24915.0; HiKHz: 24929.0; Mode: dxmDigi),
(LoKHz: 24931.0; HiKHz: 24990.0; Mode: dxmSSB),
(LoKHz: 28000.0; HiKHz: 28070.0; Mode: dxmCW),
(LoKHz: 28070.0; HiKHz: 28190.0; Mode: dxmDigi),
(LoKHz: 28300.0; HiKHz: 29100.0; Mode: dxmSSB),
(LoKHz: 29100.0; HiKHz: 29700.0; Mode: dxmFM),
// ── УКВ (минимум, где регионы согласны) ──
(LoKHz: 50000.0; HiKHz: 50100.0; Mode: dxmCW),
(LoKHz: 50100.0; HiKHz: 50500.0; Mode: dxmSSB));
function DXModeFromFreq(FreqHz: Double): TDXMode;
var
K: Double;
i: Integer;
begin
Result := dxmUnknown;
if FreqHz <= 0 then Exit;
// QO-100 (узкополосный транспондер, DOWNLINK). Отдельно от КВ-таблицы: план
// AMSAT-DL, границы те же, что рисует BandPlanOverlay. Маяки (…500-505 и
// …745-755) и «mixed modes» (…850-990) — не гадаем.
K := FreqHz / 1000.0;
if (K >= 10489500.0) and (K < 10490000.0) then
begin
if (K >= 10489505.0) and (K < 10489540.0) then Result := dxmCW
else if (K >= 10489540.0) and (K < 10489650.0) then Result := dxmDigi
else if (K >= 10489650.0) and (K < 10489745.0) then Result := dxmSSB
else if (K >= 10489755.0) and (K < 10489850.0) then Result := dxmSSB;
Exit;
end;
for i := Low(DX_BAND_PLAN) to High(DX_BAND_PLAN) do
if (K >= DX_BAND_PLAN[i].LoKHz) and (K < DX_BAND_PLAN[i].HiKHz) then
Exit(DX_BAND_PLAN[i].Mode);
end;
{ ── TDXSpotStore ─────────────────────────────────────────────────────────── }
constructor TDXSpotStore.Create;
begin
inherited Create;
FLock := TCriticalSection.Create;
FCount := 0;
FVersion := 0;
FTTLMinutes := DX_DEFAULT_TTL;
FMaxSpots := DX_DEFAULT_MAX;
SetLength(FSpots, DX_DEFAULT_MAX);
end;
destructor TDXSpotStore.Destroy;
begin
FLock.Free;
inherited Destroy;
end;
procedure TDXSpotStore.PurgeLocked;
// Выбрасывает споты старше TTL, уплотняя массив на месте.
var
i, Dst: Integer;
Cutoff: TDateTime;
begin
if FTTLMinutes <= 0 then Exit;
Cutoff := IncMinute(Now, -FTTLMinutes);
Dst := 0;
for i := 0 to FCount - 1 do
if FSpots[i].Stamp >= Cutoff then
begin
if Dst <> i then FSpots[Dst] := FSpots[i];
Inc(Dst);
end;
if Dst <> FCount then
begin
FCount := Dst;
Inc(FVersion);
end;
end;
procedure TDXSpotStore.DropOldestLocked;
var i, Oldest: Integer;
begin
if FCount <= 0 then Exit;
Oldest := 0;
for i := 1 to FCount - 1 do
if FSpots[i].Stamp < FSpots[Oldest].Stamp then Oldest := i;
for i := Oldest to FCount - 2 do FSpots[i] := FSpots[i + 1];
Dec(FCount);
end;
procedure TDXSpotStore.Add(const S: TDXSpot);
var
i: Integer;
UCall: string;
begin
if S.Call = '' then Exit;
FLock.Enter;
try
PurgeLocked;
UCall := UpperCase(S.Call);
for i := 0 to FCount - 1 do
if (UpperCase(FSpots[i].Call) = UCall) and
(Abs(FSpots[i].FreqHz - S.FreqHz) <= DX_DEDUP_HZ) then
begin
FSpots[i] := S; // тот же спот — обновляем целиком
Inc(FVersion);
Exit;
end;
if Length(FSpots) < FMaxSpots then SetLength(FSpots, FMaxSpots);
while FCount >= FMaxSpots do DropOldestLocked;
FSpots[FCount] := S;
Inc(FCount);
Inc(FVersion);
finally
FLock.Leave;
end;
end;
procedure TDXSpotStore.Purge;
begin
FLock.Enter;
try
PurgeLocked; // сам поднимет Version, если что-то выбросил
finally
FLock.Leave;
end;
end;
procedure TDXSpotStore.Clear;
begin
FLock.Enter;
try
if FCount = 0 then Exit;
FCount := 0;
Inc(FVersion);
finally
FLock.Leave;
end;
end;
function CompareSpotFreq(const A, B: TDXSpot): Integer;
begin
if A.FreqHz < B.FreqHz then Result := -1
else if A.FreqHz > B.FreqHz then Result := 1
else Result := 0;
end;
procedure SortByFreq(var Arr: TDXSpotArray; N: Integer);
// Вставками: N здесь десятки-сотни и массив почти всегда уже почти
// отсортирован (споты приходят вперемешку, но окно узкое).
var i, j: Integer; T: TDXSpot;
begin
for i := 1 to N - 1 do
begin
T := Arr[i];
j := i - 1;
while (j >= 0) and (CompareSpotFreq(Arr[j], T) > 0) do
begin
Arr[j + 1] := Arr[j];
Dec(j);
end;
Arr[j + 1] := T;
end;
end;
function TDXSpotStore.Snapshot(out Arr: TDXSpotArray): Integer;
var i: Integer;
begin
FLock.Enter;
try
PurgeLocked;
SetLength(Arr, FCount);
for i := 0 to FCount - 1 do Arr[i] := FSpots[i];
Result := FCount;
finally
FLock.Leave;
end;
SortByFreq(Arr, Result);
end;
function TDXSpotStore.SnapshotRange(LoHz, HiHz: Double; Modes: TDXModeSet;
MaxAgeMin: Integer; out Arr: TDXSpotArray): Integer;
var
i, N: Integer;
Cutoff: TDateTime;
UseAge: Boolean;
begin
N := 0;
UseAge := MaxAgeMin > 0;
Cutoff := 0;
if UseAge then Cutoff := IncMinute(Now, -MaxAgeMin);
FLock.Enter;
try
PurgeLocked;
SetLength(Arr, FCount);
for i := 0 to FCount - 1 do
begin
if (FSpots[i].FreqHz < LoHz) or (FSpots[i].FreqHz > HiHz) then Continue;
if (Modes <> []) and not (FSpots[i].Mode in Modes) then Continue;
if UseAge and (FSpots[i].Stamp < Cutoff) then Continue;
Arr[N] := FSpots[i];
Inc(N);
end;
SetLength(Arr, N);
Result := N;
finally
FLock.Leave;
end;
SortByFreq(Arr, Result);
end;
function TDXSpotStore.GetTTLMinutes: Integer;
begin
FLock.Enter;
try
Result := FTTLMinutes;
finally
FLock.Leave;
end;
end;
procedure TDXSpotStore.SetTTLMinutes(V: Integer);
begin
if V < 1 then V := 1;
FLock.Enter;
try
if FTTLMinutes = V then Exit;
FTTLMinutes := V;
PurgeLocked; // укоротили TTL — лишнее выбрасываем сразу
finally
FLock.Leave;
end;
end;
function TDXSpotStore.GetMaxSpots: Integer;
begin
FLock.Enter;
try
Result := FMaxSpots;
finally
FLock.Leave;
end;
end;
procedure TDXSpotStore.SetMaxSpots(V: Integer);
begin
if V < 16 then V := 16;
FLock.Enter;
try
if FMaxSpots = V then Exit;
FMaxSpots := V;
if Length(FSpots) < FMaxSpots then SetLength(FSpots, FMaxSpots);
while FCount > FMaxSpots do
begin
DropOldestLocked;
Inc(FVersion);
end;
finally
FLock.Leave;
end;
end;
function TDXSpotStore.Version: Int64;
begin
FLock.Enter;
try
Result := FVersion;
finally
FLock.Leave;
end;
end;
function TDXSpotStore.Count: Integer;
begin
FLock.Enter;
try
Result := FCount;
finally
FLock.Leave;
end;
end;
end.
+4
View File
@@ -69,6 +69,10 @@ type
property Visible; property Visible;
property OnClick; property OnClick;
property OnDblClick; property OnDblClick;
// Клавиатура: собственный KeyDown обрабатывает стрелки/PgUp/Home, а
// необработанные клавиши (Enter и прочее) достаются владельцу через
// inherited — поэтому событие имеет смысл публиковать.
property OnKeyDown;
end; end;
implementation implementation
+4
View File
@@ -23,6 +23,7 @@ type
FScrollBar: TOverlayScrollBar; FScrollBar: TOverlayScrollBar;
FTheme: TAppTheme; FTheme: TAppTheme;
FSyncing: Boolean; FSyncing: Boolean;
FOnChange: TNotifyEvent;
function GetLines: TStrings; function GetLines: TStrings;
function GetReadOnly: Boolean; function GetReadOnly: Boolean;
procedure SetReadOnly(AValue: Boolean); procedure SetReadOnly(AValue: Boolean);
@@ -65,6 +66,8 @@ type
property TabOrder; property TabOrder;
property TabStop; property TabStop;
property Visible; property Visible;
// Правка текста пользователем (проброс OnChange внутреннего TMemo).
property OnChange: TNotifyEvent read FOnChange write FOnChange;
end; end;
implementation implementation
@@ -192,6 +195,7 @@ end;
procedure TFlatMemo.MemoChange(Sender: TObject); procedure TFlatMemo.MemoChange(Sender: TObject);
begin begin
UpdateScrollBar; UpdateScrollBar;
if Assigned(FOnChange) then FOnChange(Self);
end; end;
procedure TFlatMemo.MemoKeyUp(Sender: TObject; var Key: Word; procedure TFlatMemo.MemoKeyUp(Sender: TObject; var Key: Word;
+402 -11
View File
@@ -22,8 +22,9 @@ unit MainForm;
interface interface
uses uses
Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, FilterPopup, Classes, SysUtils, StrUtils, DateUtils, FreqDisplay, DeviceForm, FilterPopup,
VfoOverlay, SampleRateOverlay, BandPlanOverlay, VfoOverlay, SampleRateOverlay, BandPlanOverlay,
DXSpotStore, DXClusterClient, DXSpotOverlay, DXClusterForm,
FlatButton, FlatSlider, FlatDropDown, OverlayScrollBar, AppTheme, FlatButton, FlatSlider, FlatDropDown, OverlayScrollBar, AppTheme,
Forms, Controls, Graphics, Dialogs, Forms, Controls, Graphics, Dialogs,
StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs, StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs,
@@ -139,7 +140,18 @@ type
FSliceDrag: Boolean; // тащим маркер несущей слайса по спектру FSliceDrag: Boolean; // тащим маркер несущей слайса по спектру
FSliceDragId: Integer; // какой слайс тащим FSliceDragId: Integer; // какой слайс тащим
FSampleRateOverlay: TSampleRateOverlay; // оверлей span/hide слева вверху спектра FSampleRateOverlay: TSampleRateOverlay; // оверлей span/hide слева вверху спектра
FBandPlanOverlay: TBandPlanOverlay; // полоска бэндплана QO-100 внизу спектра FBandPlanOverlay: TBandPlanOverlay; // полоска бэндплана QO-100
// ── DX-кластер ────────────────────────────────────────────────────────
FDXStore: TDXSpotStore; // общая база спотов (поток кластера пишет)
FDXClient: TDXClusterClient; // telnet-соединение (свой поток)
FDXSpotOverlay: TDXSpotOverlay; // подписи спотов на спектре
FDXClusterForm: TDXClusterForm; // окно списка (ПКМ по кнопке DX)
FDXCfg: TDXClusterSettings;
FDXConnCfg: TDXClusterSettings; // параметры, с которыми поднят клиент
FDXPendingCfg: TDXClusterSettings; // правки SETUP, ждущие тишины
FDXPending: Boolean;
FDXPendingAt: TDateTime;
FDXAgeTickAt: TDateTime; // когда последний раз гасили старые споты внизу спектра
FPanelHidden: Boolean; // True = левая панель скрыта FPanelHidden: Boolean; // True = левая панель скрыта
FLeftPanelW: Integer; // ширина панели до скрытия (DPI-safe restore) FLeftPanelW: Integer; // ширина панели до скрытия (DPI-safe restore)
FPendingBoardType: Integer; // BoardType устройства для подключения FPendingBoardType: Integer; // BoardType устройства для подключения
@@ -360,6 +372,8 @@ type
LblAtt: TLabel; LblAtt: TLabel;
LblAttVal: TLabel; LblAttVal: TLabel;
TrkAtt: TFlatSlider; TrkAtt: TFlatSlider;
// Ряд CTUN | DX | Channel | BEACON.
BtnDXCluster: TFlatButton; // ЛКМ — споты на спектре, ПКМ — окно кластера
BtnChannels: TFlatButton; BtnChannels: TFlatButton;
FChannelsDropDown: TFlatDropDown; FChannelsDropDown: TFlatDropDown;
// TX-профиль: кнопка с именем активного (блок TX, ряд DUP) + список. // TX-профиль: кнопка с именем активного (блок TX, ряд DUP) + список.
@@ -497,6 +511,22 @@ type
procedure BtnRxMuteClick(Sender: TObject); procedure BtnRxMuteClick(Sender: TObject);
procedure UpdateRxMuteButton; // видимость (Pluto+full-duplex) + стиль кнопки RX MUTE procedure UpdateRxMuteButton; // видимость (Pluto+full-duplex) + стиль кнопки RX MUTE
procedure UpdateBandPlanOverlay; // вкл/выкл полоски бэндплана QO-100 по InQO100 procedure UpdateBandPlanOverlay; // вкл/выкл полоски бэндплана QO-100 по InQO100
// ── DX-кластер ────────────────────────────────────────────────────────
procedure InitDXCluster; // стор+клиент+оверлей, чтение настроек
procedure ServiceDXCluster; // тик затухания спотов (редкий)
procedure ApplyDXClusterSettings(const D: TDXClusterSettings);
procedure AttachPanDXSpots(P: TPanafallPanel); // свой оверлей спотов на пан
procedure ApplyDXOverlayCfg(O: TDXSpotOverlay); // настройки вида → один оверлей
procedure DXTuneSpotOnPan(P: TPanafallPanel; FreqHz: Double; Mode: TDXMode);
procedure UpdateDXClusterButton;
procedure BtnDXClusterClick(Sender: TObject);
procedure BtnDXClusterMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure ShowDXClusterForm;
procedure ApplyDXConnection(const D: TDXClusterSettings);
procedure OnDXClusterSettingsChange(const D: TDXClusterSettings);
procedure CommitDXClusterSettings;
procedure OnDXTuneSpot(FreqHz: Double; Mode: TDXMode);
procedure BtnTUNClick(Sender: TObject); procedure BtnTUNClick(Sender: TObject);
procedure ApplyTUN(Active: Boolean); procedure ApplyTUN(Active: Boolean);
// PureSignal: ЛКМ по PS = вкл/выкл, ПКМ = поповер настроек. // PureSignal: ЛКМ по PS = вкл/выкл, ПКМ = поповер настроек.
@@ -1357,9 +1387,14 @@ begin
end; end;
procedure TMainForm.FormDestroy(Sender: TObject); procedure TMainForm.FormDestroy(Sender: TObject);
var i: Integer;
begin begin
FMeterTimer.Enabled := False; FMeterTimer.Enabled := False;
FSpectrumTimer.Enabled := False; FSpectrumTimer.Enabled := False;
// Поток кластера будим ПЕРВЫМ делом, не дожидаясь его: пока идёт (небыстрая)
// разборка радио, он успевает выйти сам, и join в FreeAndNil(FDXClient) ниже
// уже никого не ждёт. Иначе окно висело бы на выходе.
if FDXClient <> nil then FDXClient.RequestStop;
// Грациозный стоп радио (Run=0) до освобождения; сами движки закрывает и // Грациозный стоп радио (Run=0) до освобождения; сами движки закрывает и
// освобождает TRadioController.Destroy (FreeEngines) ниже. // освобождает TRadioController.Destroy (FreeEngines) ниже.
if FController.FNetwork.Running then if FController.FNetwork.Running then
@@ -1373,6 +1408,22 @@ begin
PanelSplitter := nil; PanelSplitter := nil;
PbWaterfall := nil; PbWaterfall := nil;
PbPanZoom := nil; PbPanZoom := nil;
// DX-кластер: незакоммиченную правку SETUP дописываем в конфиг (соединение
// при этом не трогаем — программа закрывается), гасим сетевой поток (его
// Stop делает shutdown сокета и ждёт поток), потом базу спотов. Оверлеи
// панов живут до гибели своих панов (ниже) и на общую базу ссылаются —
// поэтому сначала отвязываем их: после Detach оверлей неактивен и к стору
// не ходит, даже если между этими строками прилетит перерисовка.
if FDXPending then
begin
FDXPending := False;
FController.FSettings.SaveDXClusterSettings(FDXPendingCfg);
end;
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then FPans[i].DetachDXSpots;
FDXSpotOverlay := nil;
FreeAndNil(FDXClient);
FreeAndNil(FDXStore);
DestroyAllExtraPans; // доп. паны (DDC/движок/панели) — до пана 0 DestroyAllExtraPans; // доп. паны (DDC/движок/панели) — до пана 0
FreeAndNil(FPan); FreeAndNil(FPan);
FPans[0] := nil; FPans[0] := nil;
@@ -1976,6 +2027,8 @@ begin
BtnDiscover := MakeBtn(PanelToolbar, 'DISCOVER', X, 3, 80, BTN_H, BtnDiscoverClick); BtnDiscover := MakeBtn(PanelToolbar, 'DISCOVER', X, 3, 80, BTN_H, BtnDiscoverClick);
BtnStartStop := MakeBtn(PanelToolbar, 'START', X+84, 3, 76, BTN_H, BtnStartStopClick); BtnStartStop := MakeBtn(PanelToolbar, 'START', X+84, 3, 76, BTN_H, BtnStartStopClick);
BtnSettings := MakeBtn(PanelToolbar, 'SETUP', X+164, 3, 66, BTN_H, BtnSettingsClick); BtnSettings := MakeBtn(PanelToolbar, 'SETUP', X+164, 3, 66, BTN_H, BtnSettingsClick);
// DX живёт в блоке RX левой панели, рядом с CTUN (см. ниже): споты — часть
// приёмного вида, а не глобальная команда уровня DISCOVER/START/SETUP.
// DISCOVER — синеватый акцент // DISCOVER — синеватый акцент
BtnDiscover.ClrNorm := TColor($00101828); BtnDiscover.ClrNorm := TColor($00101828);
@@ -2231,7 +2284,7 @@ begin
Inc(Y, 100); Inc(Y, 100);
// RX block: CTUN + VOL + NR/NB/SNB/ANF/MUTE // RX block: CTUN + DX + Channel + BEACON, VOL, NR/NB/SNB/ANF/MUTE
PanelRXBlock := TPanel.Create(Self); PanelRXBlock := TPanel.Create(Self);
PanelRXBlock.Parent := PanelLeft; PanelRXBlock.Parent := PanelLeft;
PanelRXBlock.SetBounds(0, Y, LEFT_W, RX_BLOCK_H_ATT); PanelRXBlock.SetBounds(0, Y, LEFT_W, RX_BLOCK_H_ATT);
@@ -2239,15 +2292,23 @@ begin
MakeLbl(PanelRXBlock, 'RX', 4, 2); MakeLbl(PanelRXBlock, 'RX', 4, 2);
BtnCTun := MakeBtn(PanelRXBlock, 'CTUN', BtnCTun := MakeBtn(PanelRXBlock, 'CTUN',
LeftPanelButtonLeft(LEFT_W, 3, 0), 20, LeftPanelButtonLeft(LEFT_W, 4, 0), 20,
LeftPanelButtonWidth(LEFT_W, 3, 0), LeftPanelButtonWidth(LEFT_W, 4, 0),
BTN_H, BtnCTunClick); BTN_H, BtnCTunClick);
BtnCTun.Tag := 0; BtnCTun.Tag := 0;
StyleButton(BtnCTun, FController.FCTun); StyleButton(BtnCTun, FController.FCTun);
// DX — споты кластера: ЛКМ включает/выключает подписи на спектре, ПКМ
// открывает окно списка (та же семантика, что у BEACON → окно констелляции).
BtnDXCluster := MakeBtn(PanelRXBlock, 'DX',
LeftPanelButtonLeft(LEFT_W, 4, 1), 20,
LeftPanelButtonWidth(LEFT_W, 4, 1),
BTN_H, BtnDXClusterClick);
BtnDXCluster.OnMouseDown := BtnDXClusterMouseDown;
BtnChannels := MakeBtn(PanelRXBlock, 'Channel', BtnChannels := MakeBtn(PanelRXBlock, 'Channel',
LeftPanelButtonLeft(LEFT_W, 3, 1), 20, LeftPanelButtonLeft(LEFT_W, 4, 2), 20,
LeftPanelButtonWidth(LEFT_W, 3, 1), LeftPanelButtonWidth(LEFT_W, 4, 2),
BTN_H, BtnChannelsClick); BTN_H, BtnChannelsClick);
BtnChannels.OnMouseDown := BtnChannelsMouseDown; BtnChannels.OnMouseDown := BtnChannelsMouseDown;
StyleButton(BtnChannels, False); StyleButton(BtnChannels, False);
@@ -2257,11 +2318,11 @@ begin
FChannelsDropDown.OnSelect := OnChannelsDropDownSelect; FChannelsDropDown.OnSelect := OnChannelsDropDownSelect;
RefreshChannelsDropDown; RefreshChannelsDropDown;
// QO-100 beacon lock — 3-я колонка ряда. Видима только в XVTR-режиме // QO-100 beacon lock — 4-я колонка ряда. Видима только в XVTR-режиме
// (см. рендер rfXvtr ниже). Подпись/подсветка обновляются по rfBeaconLock. // (см. рендер rfXvtr ниже). Подпись/подсветка обновляются по rfBeaconLock.
BtnBeacon := MakeBtn(PanelRXBlock, 'BEACON', BtnBeacon := MakeBtn(PanelRXBlock, 'BEACON',
LeftPanelButtonLeft(LEFT_W, 3, 2), 20, LeftPanelButtonLeft(LEFT_W, 4, 3), 20,
LeftPanelButtonWidth(LEFT_W, 3, 2), LeftPanelButtonWidth(LEFT_W, 4, 3),
BTN_H, BtnBeaconClick); BTN_H, BtnBeaconClick);
BtnBeacon.Visible := False; BtnBeacon.Visible := False;
BtnBeacon.OnMouseDown := BtnBeaconMouseDown; // ПКМ → декодер + окно констелляции BtnBeacon.OnMouseDown := BtnBeaconMouseDown; // ПКМ → декодер + окно констелляции
@@ -2641,6 +2702,9 @@ begin
FBandPlanOverlay.SetView(FController.FCenterFreq, FController.FSpanHz); FBandPlanOverlay.SetView(FController.FCenterFreq, FController.FSpanHz);
FSpecView.BandPlanOverlay := FBandPlanOverlay; FSpecView.BandPlanOverlay := FBandPlanOverlay;
// DX-кластер: база спотов + сетевой поток + оверлей подписей.
InitDXCluster;
// S-метр — внутри PanelToolbar, справа // S-метр — внутри PanelToolbar, справа
PanelSMeterRight := TPanel.Create(Self); PanelSMeterRight := TPanel.Create(Self);
PanelSMeterRight.Parent := PanelToolbar; PanelSMeterRight.Parent := PanelToolbar;
@@ -3316,6 +3380,8 @@ begin
StyleButton(Btn2Ton, Btn2Ton.Active); StyleButton(Btn2Ton, Btn2Ton.Active);
StyleButton(BtnRxMute, BtnRxMute.Active); StyleButton(BtnRxMute, BtnRxMute.Active);
StyleButton(BtnBeacon, BtnBeacon.Active); StyleButton(BtnBeacon, BtnBeacon.Active);
// DX — тумблер подписей спотов, состояние берём из самой кнопки (сосед CTUN).
if BtnDXCluster <> nil then StyleButton(BtnDXCluster, BtnDXCluster.Active);
if BtnFMSQ <> nil then StyleButton(BtnFMSQ, BtnFMSQ.Active); if BtnFMSQ <> nil then StyleButton(BtnFMSQ, BtnFMSQ.Active);
if TrkFMSQ <> nil then DS(TrkFMSQ); if TrkFMSQ <> nil then DS(TrkFMSQ);
if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, BtnFMCTCSS.Active); if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, BtnFMCTCSS.Active);
@@ -3337,6 +3403,9 @@ begin
if FPans[i] <> nil then if FPans[i] <> nil then
begin begin
FPans[i].View.SetTheme(T); FPans[i].View.SetTheme(T);
// Палитра подписей спотов парная под тему — у каждого пана свой оверлей.
if FPans[i].DXOverlay <> nil then
FPans[i].DXOverlay.SetLightTheme(FLightTheme);
if FPans[i].PbSpectrum is TPaintBox then if FPans[i].PbSpectrum is TPaintBox then
TPaintBox(FPans[i].PbSpectrum).Color := T.BG; TPaintBox(FPans[i].PbSpectrum).Color := T.BG;
if FPans[i].PbWaterfall is TPaintBox then if FPans[i].PbWaterfall is TPaintBox then
@@ -3451,6 +3520,8 @@ begin
if FCWMsgForm <> nil then TCWMessagesForm(FCWMsgForm).ApplyTheme(T); if FCWMsgForm <> nil then TCWMessagesForm(FCWMsgForm).ApplyTheme(T);
if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).ApplyTheme(T); if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).ApplyTheme(T);
if FBeaconScopeForm <> nil then FBeaconScopeForm.ApplyTheme(T); if FBeaconScopeForm <> nil then FBeaconScopeForm.ApplyTheme(T);
if FDXClusterForm <> nil then FDXClusterForm.ApplyTheme(T);
// Оверлеи спотов перекрашены в ApplyDarkTheme — общим циклом по FPans.
FController.FSettings.SaveTheme(V); FController.FSettings.SaveTheme(V);
FController.FSettings.Save; FController.FSettings.Save;
if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
@@ -3690,6 +3761,9 @@ begin
// отсекает неизменное → пересборки кэша на каждый вызов нет. // отсекает неизменное → пересборки кэша на каждый вызов нет.
if Assigned(FBandPlanOverlay) then if Assigned(FBandPlanOverlay) then
FBandPlanOverlay.SetView(ViewCenter, ViewSpan); FBandPlanOverlay.SetView(ViewCenter, ViewSpan);
// Подписи спотов — тот же видимый домен, что у бэндплана.
if Assigned(FDXSpotOverlay) then
FDXSpotOverlay.SetView(ViewCenter, ViewSpan);
if UpdateWidebandFrequencyView and FController.FShowWideband and (PbWideband <> nil) then if UpdateWidebandFrequencyView and FController.FShowWideband and (PbWideband <> nil) then
PbWideband.Invalidate; PbWideband.Invalidate;
UpdatePanZoomBar; // линейка пана следует за частотой/зумом (no-op если без изменений) UpdatePanZoomBar; // линейка пана следует за частотой/зумом (no-op если без изменений)
@@ -3714,6 +3788,9 @@ var
PeakDiff, MinDiff: Double; PeakDiff, MinDiff: Double;
PeakAlpha, MinAlpha: Double; PeakAlpha, MinAlpha: Double;
begin begin
// Затухание спотов не зависит от того, крутится ли приём.
ServiceDXCluster;
if not FController.FRunning then if not FController.FRunning then
begin begin
if PbSMeterRight <> nil then PbSMeterRight.Invalidate; if PbSMeterRight <> nil then PbSMeterRight.Invalidate;
@@ -4947,6 +5024,293 @@ begin
StyleButton(BtnRxMute, FController.FRxMuteOnTx); StyleButton(BtnRxMute, FController.FRxMuteOnTx);
end; end;
// ═══════════════════════════════════════════════════════════════════════════
// DX-кластер: база спотов + telnet-клиент + оверлей подписей на спектре
// ═══════════════════════════════════════════════════════════════════════════
function DXModeSetFromMask(Mask: LongWord): TDXModeSet;
// Маска настроек → множество мод. 0 = без фильтра (показываем всё).
var M: TDXMode;
begin
Result := [];
if Mask = 0 then Exit;
for M := Low(TDXMode) to High(TDXMode) do
if (Mask and (LongWord(1) shl Ord(M))) <> 0 then Include(Result, M);
end;
procedure TMainForm.InitDXCluster;
begin
FDXStore := TDXSpotStore.Create;
FDXClient := TDXClusterClient.Create(FDXStore);
// Оверлей пана 0 живёт в самом пане, как и у панов N; FDXSpotOverlay — только
// алиас на него (как FSpecView для FPan.View), чтобы главный пан не был
// особым случаем в настройках/теме/тике.
FPan.AttachDXSpots(FDXStore);
FDXSpotOverlay := FPan.DXOverlay;
FDXSpotOverlay.SetLightTheme(FLightTheme);
FDXSpotOverlay.SetView(FController.FCenterFreq, FController.FSpanHz);
FDXAgeTickAt := Now;
FController.FSettings.LoadDXClusterSettings(FDXCfg);
FDXStore.TTLMinutes := FDXCfg.TTLMinutes;
ApplyDXClusterSettings(FDXCfg);
// FDXConnCfg пуст → ApplyDXConnection увидит смену параметров и поднимет
// соединение, если оно включено и позывной задан.
ApplyDXConnection(FDXCfg);
end;
procedure TMainForm.ApplyDXClusterSettings(const D: TDXClusterSettings);
// Вид: оверлей + кнопка. Применяется сразу на каждую правку в SETUP — это
// дёшево, обратимо и даёт живой отклик. Всё, что МЕНЯЕТ ДАННЫЕ (TTL стора) или
// трогает соединение, откладывается до CommitDXClusterSettings: набирая «30» в
// поле TTL, пользователь на миг проходит через «3», а это выбросило бы из базы
// все споты старше трёх минут — необратимо.
var i: Integer;
begin
FDXCfg := D;
// Настройки общие для всех панов: споты рисуются на каждом, где частота
// попадает в его окно.
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then
begin
ApplyDXOverlayCfg(FPans[i].DXOverlay);
if i > 0 then MarkPanDirty(FPans[i]);
end;
UpdateDXClusterButton;
FSpectrumDirty := True;
end;
procedure TMainForm.ApplyDXOverlayCfg(O: TDXSpotOverlay);
// Вид одного оверлея по текущему FDXCfg (сеттеры сами гасят «то же самое»).
begin
if O = nil then Exit;
O.MaxRows := FDXCfg.MaxRows;
O.SetOwnCall(FDXCfg.Login);
O.SetFilters(DXModeSetFromMask(FDXCfg.ModeMask), FDXCfg.TTLMinutes);
O.SetEnabled(FDXCfg.ShowSpots);
O.Invalidate;
end;
procedure TMainForm.AttachPanDXSpots(P: TPanafallPanel);
// Доп. пан получает СВОЙ оверлей на общей базе спотов: кэш полосы подписей
// ключуется центром/спаном/шириной пана, поэтому один экземпляр на всех
// пересобирался бы по кругу на каждый пан каждый кадр — ровно то, ради чего
// кэш и заводился.
var VC, VS: Double;
begin
if (P = nil) or (FDXStore = nil) then Exit;
P.AttachDXSpots(FDXStore);
ApplyDXOverlayCfg(P.DXOverlay);
P.DXOverlay.SetLightTheme(FLightTheme);
PanViewWindow(P, VC, VS);
P.DXOverlay.SetView(VC, VS);
end;
procedure TMainForm.ApplyDXConnection(const D: TDXClusterSettings);
// Соединение: конфиг в клиент + поднять/положить/переподнять. Поток снимает
// конфиг один раз на сессию, поэтому смена адреса/учётки требует рестарта —
// как у веб-сервера при смене порта.
var Reconnect: Boolean;
begin
if FDXClient = nil then Exit;
Reconnect := (D.Host <> FDXConnCfg.Host) or (D.Port <> FDXConnCfg.Port) or
(D.Login <> FDXConnCfg.Login) or
(D.Password <> FDXConnCfg.Password) or
(D.PostLogin <> FDXConnCfg.PostLogin);
FDXConnCfg := D;
FDXClient.Configure(D.Host, D.Port, D.Login, D.Password, D.PostLogin);
// Без позывного логиниться нечем — не поднимаем соединение вообще.
if (not D.Enabled) or (Trim(D.Login) = '') then
begin
if FDXClient.Running then FDXClient.Stop;
Exit;
end;
if FDXClient.Running then
begin
if not Reconnect then Exit;
FDXClient.Stop;
end;
FDXClient.Start;
end;
procedure TMainForm.OnDXClusterSettingsChange(const D: TDXClusterSettings);
// Каждая правка в SETUP прилетает сюда ПОСИМВОЛЬНО (OnChange поля). Вид
// применяем сразу, а запись в конфиг и переподключение откладываем: иначе
// набор позывного «UA3XYZ» дёргал бы шесть реконнектов и шесть записей JSON.
begin
ApplyDXClusterSettings(D);
FDXPendingCfg := D;
FDXPendingAt := Now;
FDXPending := True;
end;
procedure TMainForm.CommitDXClusterSettings;
// Отложенный коммит правок SETUP: пользователь перестал печатать.
begin
FDXPending := False;
FController.FSettings.SaveDXClusterSettings(FDXPendingCfg);
if FDXStore <> nil then FDXStore.TTLMinutes := FDXPendingCfg.TTLMinutes;
ApplyDXConnection(FDXPendingCfg);
end;
procedure TMainForm.ServiceDXCluster;
// Раз в DX_AGE_TICK_SEC подталкиваем оверлей пересобраться: споты тускнеют с
// возрастом и уходят по TTL, а без нового спота версия стора не меняется и
// повода для пересборки бы не было. Реже — незаметно, чаще — впустую.
const
DX_AGE_TICK_SEC = 30;
DX_SETTINGS_QUIET_MS = 1200; // тишина в SETUP, после которой коммитим
var i: Integer;
begin
// Отложенный коммит настроек: пользователь перестал печатать в SETUP.
if FDXPending and (MilliSecondsBetween(Now, FDXPendingAt) >= DX_SETTINGS_QUIET_MS) then
CommitDXClusterSettings;
if SecondsBetween(Now, FDXAgeTickAt) < DX_AGE_TICK_SEC then Exit;
FDXAgeTickAt := Now;
// TTL — свойство базы, а не картинки. Стор чистится только внутри Add и
// снимков, поэтому с выключенными подписями и молчащим (или отключённым)
// кластером просроченные споты не выбрасывал бы никто: новых Add нет, версия
// стора не меняется, окно списка снимок не берёт, тик оверлея выключен.
if FDXStore <> nil then FDXStore.Purge;
if FDXSpotOverlay = nil then Exit;
if not FDXSpotOverlay.Active then Exit;
// Тик получают оверлеи всех панов: возраст спота от пана не зависит.
for i := 0 to MAX_PANS - 1 do
if (FPans[i] <> nil) and (FPans[i].DXOverlay <> nil) then
begin
FPans[i].DXOverlay.TickAge;
if i > 0 then MarkPanDirty(FPans[i]);
end;
FSpectrumDirty := True;
end;
procedure TMainForm.UpdateDXClusterButton;
begin
if BtnDXCluster = nil then Exit;
StyleButton(BtnDXCluster,
(FDXSpotOverlay <> nil) and FDXSpotOverlay.Active);
end;
procedure TMainForm.BtnDXClusterClick(Sender: TObject);
// ЛКМ — показывать/не показывать подписи спотов на спектре (настройка живёт
// в конфиге, чтобы состояние пережило перезапуск). Кнопка одна на все паны:
// применяем через общий Apply, иначе тумблер гасил бы только главный.
begin
if FDXSpotOverlay = nil then Exit;
FDXCfg.ShowSpots := not FDXCfg.ShowSpots;
ApplyDXClusterSettings(FDXCfg);
FController.FSettings.SaveDXClusterSettings(FDXCfg);
// Правки SETUP ждут тишины в СВОЕЙ копии конфига. Не поправив её, отложенный
// коммит записал бы старый FDXPendingCfg целиком и молча вернул прежнее
// состояние тумблера — на экране новое, в файле старое.
if FDXPending then FDXPendingCfg.ShowSpots := FDXCfg.ShowSpots;
// Открытый SETUP держит СВОЮ копию конфига: без синхронизации галка «Show
// spots» осталась бы старой, и первая же правка любого поля на этой странице
// вернула бы подписи обратно.
if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
TSettingsForm(FSettingsForm).LoadDXClusterSettings(FDXCfg);
end;
procedure TMainForm.BtnDXClusterMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
if Button = mbRight then ShowDXClusterForm;
end;
procedure TMainForm.ShowDXClusterForm;
begin
if FDXClusterForm = nil then
begin
FDXClusterForm := TDXClusterForm.CreateWith(Self, FDXStore, FDXClient);
FDXClusterForm.OnTuneSpot := OnDXTuneSpot;
end;
FDXClusterForm.ApplyTheme(CurrentAppTheme);
if FDXClusterForm.Visible then FDXClusterForm.Hide
else FDXClusterForm.Show;
end;
function DXSpotRadioMode(FreqHz: Double; Mode: TDXMode): Integer;
// Мода спота → режим приёмника. −1 = однозначно не определяется (dxmUnknown):
// текущую в этом случае не трогаем — гадать хуже, чем не менять.
begin
Result := -1;
case Mode of
dxmCW: Result := MODE_CWU;
// Боковая по общепринятому правилу: ниже 10 МГц — LSB, выше — USB.
dxmSSB: if FreqHz < 10000000.0 then Result := MODE_LSB else Result := MODE_USB;
dxmFT8, dxmFT4, dxmDigi, dxmRTTY, dxmPSK: Result := MODE_DIGU;
dxmSSTV: Result := MODE_USB;
dxmFM: Result := MODE_FM;
end;
end;
procedure TMainForm.OnDXTuneSpot(FreqHz: Double; Mode: TDXMode);
// QSY по споту: клик по подписи на главном пане или двойной клик в окне списка.
// Крутим ТОТ VFO, который сейчас активен, — ровно как обычный клик по спектру
// (DoSpectrumClick): иначе при активном B спот уводил бы неактивный A.
var NewMode: Integer;
begin
if FreqHz <= 0 then Exit;
if FController.FActiveVfo = 0 then
ApplyVfoA(Round(FreqHz))
else
FController.SetVfoB(Round(FreqHz)); // рендер через OnControllerState(rfVfoB)
NewMode := DXSpotRadioMode(FreqHz, Mode);
if (NewMode >= 0) and (NewMode <> FController.FMode) then
FController.SetMode(NewMode);
end;
procedure TMainForm.DXTuneSpotOnPan(P: TPanafallPanel; FreqHz: Double;
Mode: TDXMode);
// QSY по споту на доп. пане. Цель — слайс ЭТОГО пана (активный, если он здесь,
// иначе первый; нет ни одного — создаём в точке), ровно как у клика по фону
// пана: своего VFO у панов N нет, а ретюнить их DDC под спот нельзя — это
// увезло бы весь пан.
var
Id, NewMode: Integer;
Hz: Int64;
S: TCtrlSlice;
begin
if (P = nil) or (FreqHz <= 0) then Exit;
Hz := Round(FreqHz);
// Спот видим в окне пана, но окно может быть шире полосы захвата (зум) —
// слайс вне capture не поставить.
if not FController.SliceFitsCapture(Hz, P.PanId) then Exit;
if (FActiveSliceId <> 0) and FController.GetSlice(FActiveSliceId, S) and
(S.PanId = P.PanId) then
Id := FActiveSliceId
else
Id := P.FirstSliceId;
NewMode := DXSpotRadioMode(FreqHz, Mode);
if Id = 0 then
begin
AddSliceAtFreqPan(P, Hz); // ставит FActiveSliceId
Id := FActiveSliceId;
if Id = 0 then Exit;
end
else
FController.SetSliceTarget(Id, Hz);
// Режим слайса — с полосой пресета ЭТОГО режима: тащить полосу с главного
// приёмника (как делает AddSliceAtFreqPan для нового слайса) значило бы
// слушать телеграф в 2.7 кГц.
if NewMode >= 0 then
FController.SetSliceModeBW(Id, NewMode,
FController.FilterBWFor(NewMode, FilterDefIdx(NewMode)));
FActiveSliceId := Id;
P.PushSliceFlagState(Id);
P.LayoutFlags;
MarkPanDirty(P);
end;
procedure TMainForm.UpdateBandPlanOverlay; procedure TMainForm.UpdateBandPlanOverlay;
// Полоска бэндплана видна только в QO-100 (Pluto + full-duplex транспондер). // Полоска бэндплана видна только в QO-100 (Pluto + full-duplex транспондер).
begin begin
@@ -5251,7 +5615,9 @@ end;
// Спектр // Спектр
procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton; procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer); Shift: TShiftState; X, Y: Integer);
var VC, VS: Double; var
VC, VS: Double;
DXSpot: TDXSpot;
begin begin
if FPan.DispatchFlagsMouseDown(Button, X, Y) then if FPan.DispatchFlagsMouseDown(Button, X, Y) then
begin begin
@@ -5261,6 +5627,15 @@ begin
if Assigned(FSampleRateOverlay) and if Assigned(FSampleRateOverlay) and
FSampleRateOverlay.HandleMouseDown(Button, X, Y) then Exit; FSampleRateOverlay.HandleMouseDown(Button, X, Y) then Exit;
// ЛКМ по подписи DX-спота — QSY на него. Проверяется до тюнинга/драга, но
// только в полосе подписей вверху: ниже неё поведение спектра прежнее.
if (Button = mbLeft) and Assigned(FDXSpotOverlay) and
FDXSpotOverlay.SpotAtPixel(X, Y, DXSpot) then
begin
OnDXTuneSpot(DXSpot.FreqHz, DXSpot.Mode);
Exit;
end;
// Ctrl+ЛКМ по спектру — добавить софт-слайс на этой частоте (в пределах захвата). // Ctrl+ЛКМ по спектру — добавить софт-слайс на этой частоте (в пределах захвата).
if (Button = mbLeft) and (ssCtrl in Shift) and (PbSpectrum.Width > 0) then if (Button = mbLeft) and (ssCtrl in Shift) and (PbSpectrum.Width > 0) then
begin begin
@@ -6507,6 +6882,9 @@ begin
FController.GetPanViewWindow(P.PanId, VC, VS); FController.GetPanViewWindow(P.PanId, VC, VS);
P.View.CenterFreq := VC; P.View.CenterFreq := VC;
P.View.SpanHz := VS; P.View.SpanHz := VS;
// Подписи спотов пана следуют за его окном (SetView сам отсекает неизменное
// → пересборки кэша на каждый кадр нет).
if P.DXOverlay <> nil then P.DXOverlay.SetView(VC, VS);
// Сетка каналов по шагу FM — как на главном пане, но по режиму слайсов // Сетка каналов по шагу FM — как на главном пане, но по режиму слайсов
// ЭТОГО пана (у доп. панов нет своего FMode). // ЭТОГО пана (у доп. панов нет своего FMode).
if FController.PanHasFMSlice(P.PanId) then if FController.PanHasFMSlice(P.PanId) then
@@ -7158,6 +7536,8 @@ begin
P.View.VfoA := -1e15; P.View.VfoA := -1e15;
P.View.VfoB := -1e15; P.View.VfoB := -1e15;
P.View.TXFreq := -1e15; P.View.TXFreq := -1e15;
// Подписи DX-спотов: свой оверлей на общей базе, настройки/тема — общие.
AttachPanDXSpots(P);
// Сплиттер над паном. // Сплиттер над паном.
Split := TPanel.Create(Self); Split := TPanel.Create(Self);
@@ -7539,6 +7919,7 @@ var
VC, VS: Double; VC, VS: Double;
Id, W: Integer; Id, W: Integer;
IsSpec: Boolean; IsSpec: Boolean;
DXSpot: TDXSpot;
begin begin
P := PanFromSender(Sender); P := PanFromSender(Sender);
if P = nil then Exit; if P = nil then Exit;
@@ -7550,6 +7931,14 @@ begin
P.ProcessPendingSliceClose; P.ProcessPendingSliceClose;
Exit; Exit;
end; end;
// ЛКМ по подписи DX-спота на пане — QSY слайса ЭТОГО пана (у панов N нет
// VFO A). Как на главном: только в полосе подписей, ниже поведение прежнее.
if IsSpec and (Button = mbLeft) and (P.DXOverlay <> nil) and
P.DXOverlay.SpotAtPixel(X, Y, DXSpot) then
begin
DXTuneSpotOnPan(P, DXSpot.FreqHz, DXSpot.Mode);
Exit;
end;
W := TControl(Sender).Width; W := TControl(Sender).Width;
if W <= 0 then Exit; if W <= 0 then Exit;
if (Button = mbLeft) and (ssCtrl in Shift) then if (Button = mbLeft) and (ssCtrl in Shift) then
@@ -9286,6 +9675,7 @@ begin
SF.OnWfAGCNFChange := ApplyWfAGCNF; SF.OnWfAGCNFChange := ApplyWfAGCNF;
SF.OnADCChange := ApplyADCSettings; SF.OnADCChange := ApplyADCSettings;
SF.OnWebSettingsChange := ApplyWebSettings; SF.OnWebSettingsChange := ApplyWebSettings;
SF.OnDXClusterChange := OnDXClusterSettingsChange;
end; end;
SF := TSettingsForm(FSettingsForm); SF := TSettingsForm(FSettingsForm);
PushTXProfilesToSettings; PushTXProfilesToSettings;
@@ -9340,6 +9730,7 @@ begin
SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled); SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled);
SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass, SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass,
FWebSpecPixels); FWebSpecPixels);
SF.LoadDXClusterSettings(FDXCfg);
SF.LoadFPS(FDisplayFPS); SF.LoadFPS(FDisplayFPS);
SF.LoadLightTheme(FLightTheme); SF.LoadLightTheme(FLightTheme);
SF.LoadFreqMhzDigits(FFreqMhzDigits); SF.LoadFreqMhzDigits(FFreqMhzDigits);
+28
View File
@@ -35,6 +35,7 @@ uses
LCLIntf, LCLType, LCLIntf, LCLType,
OpenGLContextEx, OpenGLContextEx,
AppTheme, WDSPEngine, RadioController, DMRDecoder, VfoOverlay, AppTheme, WDSPEngine, RadioController, DMRDecoder, VfoOverlay,
DXSpotStore, DXSpotOverlay,
SpectrumView, SpectrumViewOpengl, PanZoomBar, FlatButton, DpiUtils; SpectrumView, SpectrumViewOpengl, PanZoomBar, FlatButton, DpiUtils;
type type
@@ -83,6 +84,10 @@ type
FSliceFlags: array of TVfoOverlay; // флаги B+ (Owner=Self) FSliceFlags: array of TVfoOverlay; // флаги B+ (Owner=Self)
FActiveFlag: TVfoOverlay; // чьё событие сейчас обрабатывается FActiveFlag: TVfoOverlay; // чьё событие сейчас обрабатывается
FPendingSliceClose: Integer; // SliceId к удалению после мыши (0=нет) FPendingSliceClose: Integer; // SliceId к удалению после мыши (0=нет)
// Подписи DX-спотов на спектре ЭТОГО пана. Экземпляр свой на каждый пан:
// кэш полосы подписей ключуется центром/спаном/шириной, а у панов они свои
// — общий оверлей пересобирался бы на каждый пан каждый кадр.
FDXOverlay: TDXSpotOverlay; // Owner=Self, база спотов общая
FMainFlagPinned: Boolean; // True = левая панель хозяина скрыта FMainFlagPinned: Boolean; // True = левая панель хозяина скрыта
FMainFlagForced: Boolean; // True = есть доп. панадаптеры FMainFlagForced: Boolean; // True = есть доп. панадаптеры
FLastSliceMeterMs: QWord; // троттлинг S-метра слайсов (~10 Гц) FLastSliceMeterMs: QWord; // троттлинг S-метра слайсов (~10 Гц)
@@ -268,6 +273,14 @@ type
// полный пересчёт (у него wideband/S-метр/оверлеи). // полный пересчёт (у него wideband/S-метр/оверлеи).
property OnSplitterMoved: TNotifyEvent read FOnSplitterMoved write FOnSplitterMoved; property OnSplitterMoved: TNotifyEvent read FOnSplitterMoved write FOnSplitterMoved;
// ---- Подписи DX-спотов ----
// Своя копия оверлея на пан; база спотов (стор) — общая, её владелец хозяин.
// Вид (центр/спан) пана заливает хозяин, как и прочие параметры вьюхи.
procedure AttachDXSpots(AStore: TDXSpotStore);
// Отвязать от базы (хозяин освобождает стор раньше панов).
procedure DetachDXSpots;
property DXOverlay: TDXSpotOverlay read FDXOverlay;
// ---- Флаги слайсов ---- // ---- Флаги слайсов ----
// Главный флаг (слайс A) создаёт хозяин (проводка событий у него) и отдаёт // Главный флаг (слайс A) создаёт хозяин (проводка событий у него) и отдаёт
// сюда; панель подключает его к view и включает в раскладку/диспетчер. // сюда; панель подключает его к view и включает в раскладку/диспетчер.
@@ -1108,6 +1121,21 @@ begin
FView.VfoOverlay := O; FView.VfoOverlay := O;
end; end;
procedure TPanafallPanel.AttachDXSpots(AStore: TDXSpotStore);
begin
if FDXOverlay = nil then
begin
FDXOverlay := TDXSpotOverlay.Create(Self);
FView.DXSpotOverlay := FDXOverlay;
end;
FDXOverlay.Attach(AStore);
end;
procedure TPanafallPanel.DetachDXSpots;
begin
if FDXOverlay <> nil then FDXOverlay.Attach(nil);
end;
function TPanafallPanel.CurSliceId: Integer; function TPanafallPanel.CurSliceId: Integer;
begin begin
if Assigned(FActiveFlag) then Result := FActiveFlag.SliceId else Result := 0; if Assigned(FActiveFlag) then Result := FActiveFlag.SliceId else Result := 0;
+71
View File
@@ -427,6 +427,25 @@ type
end; end;
// Web-сервер — глобальные настройки (не привязаны к устройству, секция "web"). // Web-сервер — глобальные настройки (не привязаны к устройству, секция "web").
// DX-кластер: телнет-соединение + вид спотов на панадаптере.
// Хранится глобально (секция "dxcluster"), не per-device: кластер и позывной
// от железа не зависят.
TDXClusterSettings = record
Enabled: Boolean; // подключаться при старте
ShowSpots: Boolean; // рисовать оверлей на спектре
Host: string;
Port: Integer; // 1..65535
Login: string; // позывной для логина в кластер (он же «свой» в оверлее)
Password: string; // редкий кластер спрашивает — обычно пусто
PostLogin: string; // команды после логина (по строке; сюда же set/filter)
TTLMinutes: Integer; // сколько держать спот (он же горизонт затухания)
MaxRows: Integer; // рядов лесенки подписей (1..DXSPOT_MAX_ROWS)
// Фильтр по модам. Пустая маска = показывать все (в т.ч. неопознанные).
// Биты соответствуют TDXMode: 1=CW,2=SSB,3=DIGI,4=FT8,5=FT4,6=RTTY,7=PSK,
// 8=FM,9=SSTV; бит 0 = споты без распознанной моды.
ModeMask: LongWord;
end;
TWebSettings = record TWebSettings = record
Enabled: Boolean; Enabled: Boolean;
Port: Integer; // 1..65535 Port: Integer; // 1..65535
@@ -754,6 +773,9 @@ type
function LoadPans(const MAC: array of Byte; const Ctx: string; out P: TPansConfig): Boolean; function LoadPans(const MAC: array of Byte; const Ctx: string; out P: TPansConfig): Boolean;
procedure SavePans(const MAC: array of Byte; const Ctx: string; const P: TPansConfig); procedure SavePans(const MAC: array of Byte; const Ctx: string; const P: TPansConfig);
// Web-сервер — глобальные настройки (секция "web" в корне JSON). // Web-сервер — глобальные настройки (секция "web" в корне JSON).
class procedure DefaultDXCluster(out D: TDXClusterSettings);
procedure LoadDXClusterSettings(out D: TDXClusterSettings);
procedure SaveDXClusterSettings(const D: TDXClusterSettings);
class procedure DefaultWeb(out W: TWebSettings); class procedure DefaultWeb(out W: TWebSettings);
procedure LoadWebSettings(out W: TWebSettings); procedure LoadWebSettings(out W: TWebSettings);
procedure SaveWebSettings(const W: TWebSettings); procedure SaveWebSettings(const W: TWebSettings);
@@ -2873,6 +2895,55 @@ begin
end; end;
end; end;
class procedure TSettingsManager.DefaultDXCluster(out D: TDXClusterSettings);
begin
D.Enabled := False; // без позывного подключаться всё равно нечем
D.ShowSpots := False; // подписи включает пользователь кнопкой DX
D.Host := 'cluster.dxfun.com';
D.Port := 8000;
D.Login := '';
D.Password := '';
D.PostLogin := '';
D.TTLMinutes := 30;
D.MaxRows := 4;
D.ModeMask := 0; // 0 = без фильтра по модам
end;
procedure TSettingsManager.LoadDXClusterSettings(out D: TDXClusterSettings);
var O: TJSONObject;
begin
DefaultDXCluster(D);
if FRoot.Find('dxcluster') = nil then Exit;
O := EnsureObj(FRoot, 'dxcluster');
D.Enabled := JB(O, 'enabled', False);
D.ShowSpots := JB(O, 'show_spots', False);
D.Host := JS(O, 'host', 'cluster.dxfun.com');
D.Port := EnsureRange(JI(O, 'port', 8000), 1, 65535);
D.Login := JS(O, 'login', '');
D.Password := JS(O, 'password', '');
D.PostLogin := JS(O, 'post_login', '');
D.TTLMinutes := EnsureRange(JI(O, 'ttl_minutes', 30), 1, 1440);
D.MaxRows := EnsureRange(JI(O, 'max_rows', 4), 1, 6);
D.ModeMask := LongWord(JI(O, 'mode_mask', 0));
end;
procedure TSettingsManager.SaveDXClusterSettings(const D: TDXClusterSettings);
var O: TJSONObject;
begin
O := EnsureObj(FRoot, 'dxcluster');
JW(O, 'enabled', D.Enabled);
JW(O, 'show_spots', D.ShowSpots);
JWS(O, 'host', D.Host);
JW(O, 'port', D.Port);
JWS(O, 'login', D.Login);
JWS(O, 'password', D.Password);
JWS(O, 'post_login', D.PostLogin);
JW(O, 'ttl_minutes', D.TTLMinutes);
JW(O, 'max_rows', D.MaxRows);
JW(O, 'mode_mask', Integer(D.ModeMask));
Save;
end;
class procedure TSettingsManager.DefaultWeb(out W: TWebSettings); class procedure TSettingsManager.DefaultWeb(out W: TWebSettings);
begin begin
W.Enabled := True; W.Enabled := True;
+258 -1
View File
@@ -28,7 +28,7 @@ uses
Forms, Controls, Graphics, Dialogs, Forms, Controls, Graphics, Dialogs,
StdCtrls, ExtCtrls, LCLType, StdCtrls, ExtCtrls, LCLType,
FlatButton, FlatCheckBox, FlatComboBox, FlatEdit, FlatSpinEdit, FlatFloatSpinEdit, FlatButton, FlatCheckBox, FlatComboBox, FlatEdit, FlatSpinEdit, FlatFloatSpinEdit,
FlatRadioButton, FlatRadioButton, FlatMemo,
EqualizerControl, OverlayScrollBar, AudioOutput, AudioInput, AppTheme, Settings, EqualizerControl, OverlayScrollBar, AudioOutput, AudioInput, AppTheme, Settings,
BoardUtils, DpiUtils; BoardUtils, DpiUtils;
@@ -86,6 +86,10 @@ type
TOnThemeChange = procedure(LightTheme: Boolean) of object; TOnThemeChange = procedure(LightTheme: Boolean) of object;
TOnWfAGCNFChange = procedure(WfAGC, WfNF: Boolean) of object; TOnWfAGCNFChange = procedure(WfAGC, WfNF: Boolean) of object;
TOnADCChange = procedure(Dither, Random: Boolean) of object; TOnADCChange = procedure(Dither, Random: Boolean) of object;
// Настройки DX-кластера уезжают наружу целиком записью: полей много и
// добавлять их по одному в сигнатуру (как у веб-сервера) уже неудобно.
TOnDXClusterChange = procedure(const D: TDXClusterSettings) of object;
TOnWebSettingsChange = procedure(Enabled: Boolean; Port: Integer; TOnWebSettingsChange = procedure(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string; SpecPixels: Integer) of object; const BindAddr, User, Pass: string; SpecPixels: Integer) of object;
TOnCATChange = procedure( TOnCATChange = procedure(
@@ -124,6 +128,19 @@ type
FPageWaterfall: TScrollBox; FPageWaterfall: TScrollBox;
FPagePA: TScrollBox; FPagePA: TScrollBox;
FPageAdvanced: TScrollBox; FPageAdvanced: TScrollBox;
FPageDXCluster: TScrollBox;
// DX-кластер
FChkDXEnabled: TFlatCheckBox;
FChkDXShow: TFlatCheckBox;
FEdDXHost: TFlatEdit;
FEdDXPort: TFlatSpinEdit;
FEdDXLogin: TFlatEdit;
FEdDXPass: TFlatEdit;
FEdDXPostLogin: TFlatMemo;
FEdDXTTL: TFlatSpinEdit;
FEdDXRows: TFlatSpinEdit;
FChkDXMode: array[0..9] of TFlatCheckBox; // индекс = Ord(TDXMode)
FDXCfg: TDXClusterSettings;
FPageCAT: TScrollBox; FPageCAT: TScrollBox;
FPageSlices: TScrollBox; FPageSlices: TScrollBox;
FPageTransmit: TScrollBox; FPageTransmit: TScrollBox;
@@ -133,6 +150,7 @@ type
FNavWaterfall: TFlatButton; FNavWaterfall: TFlatButton;
FNavPA: TFlatButton; FNavPA: TFlatButton;
FNavAdvanced: TFlatButton; FNavAdvanced: TFlatButton;
FNavDXCluster: TFlatButton;
FNavCAT: TFlatButton; FNavCAT: TFlatButton;
FNavSlices: TFlatButton; FNavSlices: TFlatButton;
FNavTransmit: TFlatButton; FNavTransmit: TFlatButton;
@@ -465,6 +483,7 @@ type
FOnVHFCalChange: TOnVHFCalChange; FOnVHFCalChange: TOnVHFCalChange;
FOnADCChange: TOnADCChange; FOnADCChange: TOnADCChange;
FOnWebSettingsChange: TOnWebSettingsChange; FOnWebSettingsChange: TOnWebSettingsChange;
FOnDXClusterChange: TOnDXClusterChange;
FOnDisplayChange: TOnDisplayParamChange; FOnDisplayChange: TOnDisplayParamChange;
FOnWaterfallChange: TOnWaterfallParamChange; FOnWaterfallChange: TOnWaterfallParamChange;
FOnWfRenderChange: TOnWfRenderChange; FOnWfRenderChange: TOnWfRenderChange;
@@ -572,6 +591,8 @@ type
OnChange: TNotifyEvent): TFlatComboBox; OnChange: TNotifyEvent): TFlatComboBox;
function MakeGroupPanel(AParent: TWinControl; const Cap: string; function MakeGroupPanel(AParent: TWinControl; const Cap: string;
ALeft, ATop, AW, AH: Integer): TPanel; ALeft, ATop, AW, AH: Integer): TPanel;
procedure BuildDXClusterTab;
procedure OnDXAnyChange(Sender: TObject);
function MakeScrollPage: TScrollBox; function MakeScrollPage: TScrollBox;
procedure AddPageBottomSpace(APage: TScrollBox); procedure AddPageBottomSpace(APage: TScrollBox);
procedure UpdatePageBottomSpace(APage: TScrollBox); procedure UpdatePageBottomSpace(APage: TScrollBox);
@@ -696,6 +717,7 @@ type
ResLimit, SpeedDiv: Integer); ResLimit, SpeedDiv: Integer);
procedure LoadSpecMSAA(Samples: Integer); procedure LoadSpecMSAA(Samples: Integer);
procedure LoadADCSettings(Dither, Random: Boolean); procedure LoadADCSettings(Dither, Random: Boolean);
procedure LoadDXClusterSettings(const D: TDXClusterSettings);
procedure LoadWebSettings(Enabled: Boolean; Port: Integer; procedure LoadWebSettings(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string; SpecPixels: Integer); const BindAddr, User, Pass: string; SpecPixels: Integer);
@@ -734,6 +756,7 @@ type
property OnWfAGCNFChange: TOnWfAGCNFChange read FOnWfAGCNFChange write FOnWfAGCNFChange; property OnWfAGCNFChange: TOnWfAGCNFChange read FOnWfAGCNFChange write FOnWfAGCNFChange;
property OnADCChange: TOnADCChange read FOnADCChange write FOnADCChange; property OnADCChange: TOnADCChange read FOnADCChange write FOnADCChange;
property OnWebSettingsChange: TOnWebSettingsChange read FOnWebSettingsChange write FOnWebSettingsChange; property OnWebSettingsChange: TOnWebSettingsChange read FOnWebSettingsChange write FOnWebSettingsChange;
property OnDXClusterChange: TOnDXClusterChange read FOnDXClusterChange write FOnDXClusterChange;
end; end;
implementation implementation
@@ -1113,6 +1136,7 @@ begin
FNavCAT := MakeNavButton('CAT', 496); FNavCAT := MakeNavButton('CAT', 496);
FNavSlices := MakeNavButton('Slices', 528); FNavSlices := MakeNavButton('Slices', 528);
FNavAdvanced := MakeNavButton('Advanced', 560); FNavAdvanced := MakeNavButton('Advanced', 560);
FNavDXCluster := MakeNavButton('DX Cluster', 592);
FContentPanel := TPanel.Create(Self); FContentPanel := TPanel.Create(Self);
FContentPanel.Parent := Self; FContentPanel.Parent := Self;
@@ -1135,6 +1159,7 @@ begin
FPagePA := MakeScrollPage; FPagePA := MakeScrollPage;
FPageCalib := MakeScrollPage; FPageCalib := MakeScrollPage;
FPageAdvanced := MakeScrollPage; FPageAdvanced := MakeScrollPage;
FPageDXCluster := MakeScrollPage;
FPageCAT := MakeScrollPage; FPageCAT := MakeScrollPage;
FPageSlices := MakeScrollPage; FPageSlices := MakeScrollPage;
FPageAlex := MakeScrollPage; FPageAlex := MakeScrollPage;
@@ -1154,6 +1179,7 @@ begin
BuildPATab; BuildPATab;
BuildCalibrationTab; BuildCalibrationTab;
BuildAdvancedTab; BuildAdvancedTab;
BuildDXClusterTab;
BuildCATTab; BuildCATTab;
BuildSlicesTab; BuildSlicesTab;
BuildAlexTab; BuildAlexTab;
@@ -1168,6 +1194,7 @@ begin
AddPageBottomSpace(FPageWaterfall); AddPageBottomSpace(FPageWaterfall);
AddPageBottomSpace(FPageCalib); AddPageBottomSpace(FPageCalib);
AddPageBottomSpace(FPageCAT); AddPageBottomSpace(FPageCAT);
AddPageBottomSpace(FPageDXCluster);
AddPageBottomSpace(FPageOC); AddPageBottomSpace(FPageOC);
FBtnClose := TFlatButton.Create(Self); FBtnClose := TFlatButton.Create(Self);
@@ -1390,6 +1417,7 @@ begin
ResizePage(FPagePA, CardWidth); ResizePage(FPagePA, CardWidth);
ResizePage(FPageCalib, CardWidth); ResizePage(FPageCalib, CardWidth);
ResizePage(FPageAdvanced, CardWidth); ResizePage(FPageAdvanced, CardWidth);
ResizePage(FPageDXCluster, CardWidth);
ResizePage(FPageCAT, CardWidth); ResizePage(FPageCAT, CardWidth);
ResizePage(FPageSlices, CardWidth); ResizePage(FPageSlices, CardWidth);
ResizePage(FPageAlex, CardWidth); ResizePage(FPageAlex, CardWidth);
@@ -1408,6 +1436,7 @@ begin
else if Sender = FNavPA then SelectPage(FPagePA, FNavPA) else if Sender = FNavPA then SelectPage(FPagePA, FNavPA)
else if Sender = FNavCalib then SelectPage(FPageCalib, FNavCalib) else if Sender = FNavCalib then SelectPage(FPageCalib, FNavCalib)
else if Sender = FNavAdvanced then SelectPage(FPageAdvanced, FNavAdvanced) else if Sender = FNavAdvanced then SelectPage(FPageAdvanced, FNavAdvanced)
else if Sender = FNavDXCluster then SelectPage(FPageDXCluster, FNavDXCluster)
else if Sender = FNavCAT then SelectPage(FPageCAT, FNavCAT) else if Sender = FNavCAT then SelectPage(FPageCAT, FNavCAT)
else if Sender = FNavSlices then SelectPage(FPageSlices, FNavSlices) else if Sender = FNavSlices then SelectPage(FPageSlices, FNavSlices)
else if Sender = FNavAlex then SelectPage(FPageAlex, FNavAlex) else if Sender = FNavAlex then SelectPage(FPageAlex, FNavAlex)
@@ -1429,6 +1458,7 @@ begin
FPagePA.Visible := APage = FPagePA; FPagePA.Visible := APage = FPagePA;
FPageCalib.Visible := APage = FPageCalib; FPageCalib.Visible := APage = FPageCalib;
FPageAdvanced.Visible := APage = FPageAdvanced; FPageAdvanced.Visible := APage = FPageAdvanced;
FPageDXCluster.Visible := APage = FPageDXCluster;
FPageCAT.Visible := APage = FPageCAT; FPageCAT.Visible := APage = FPageCAT;
FPageSlices.Visible := APage = FPageSlices; FPageSlices.Visible := APage = FPageSlices;
FPageAlex.Visible := APage = FPageAlex; FPageAlex.Visible := APage = FPageAlex;
@@ -1444,6 +1474,7 @@ begin
FNavPA.Active := ANav = FNavPA; FNavPA.Active := ANav = FNavPA;
FNavCalib.Active := ANav = FNavCalib; FNavCalib.Active := ANav = FNavCalib;
FNavAdvanced.Active := ANav = FNavAdvanced; FNavAdvanced.Active := ANav = FNavAdvanced;
FNavDXCluster.Active := ANav = FNavDXCluster;
FNavCAT.Active := ANav = FNavCAT; FNavCAT.Active := ANav = FNavCAT;
FNavSlices.Active := ANav = FNavSlices; FNavSlices.Active := ANav = FNavSlices;
FNavAlex.Active := ANav = FNavAlex; FNavAlex.Active := ANav = FNavAlex;
@@ -3412,6 +3443,227 @@ begin
Lbl.Font.Size := 8; Lbl.Font.Size := 8;
end; end;
procedure TSettingsForm.BuildDXClusterTab;
const
MARGIN = 22;
GRP_PAD = 18;
LBL_W = 110;
ED_W = 220;
ROW_H = 42;
R1 = 42;
// Подписи для чекбоксов фильтра мод. Порядок = Ord(TDXMode), включая
// dxmUnknown («без моды») — иначе фильтр молча съедал бы такие споты.
MODE_CAPS: array[0..9] of string =
('no mode', 'CW', 'SSB', 'DIGI', 'FT8', 'FT4', 'RTTY', 'PSK', 'FM', 'SSTV');
var
Grp: TPanel;
Chk: TFlatCheckBox;
Ed: TFlatEdit;
Spin: TFlatSpinEdit;
Lbl: TLabel;
Y, i, CX, CY: Integer;
begin
TSettingsManager.DefaultDXCluster(FDXCfg);
MakePageHeader(FPageDXCluster, 'DX Cluster',
'Telnet connection to a DX cluster and how spots look on the panadapter.');
// ── Соединение ────────────────────────────────────────────────────────────
Grp := MakeGroupPanel(FPageDXCluster, 'Connection', MARGIN, 86, SETTINGS_CARD_W, 366);
Y := R1;
Chk := TFlatCheckBox.Create(Self);
Chk.Parent := Grp;
Chk.Caption := 'Connect at startup';
Chk.SetBounds(DpiScale(GRP_PAD), DpiScale(Y), DpiScale(300), DpiScale(22));
Chk.Font.Size := 9;
Chk.Font.Color := CLR_TEXT;
Chk.OnChange := OnDXAnyChange;
FChkDXEnabled := Chk;
Y := Y + ROW_H;
MakeLbl(Grp, 'Host:', GRP_PAD, Y + 4, LBL_W);
Ed := TFlatEdit.Create(Self);
Ed.Parent := Grp;
Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(ED_W), DpiScale(BTN_H));
Ed.Color := CLR_INPUT;
Ed.Font.Color := CLR_INPUT_TEXT;
Ed.Font.Size := 9;
Ed.Text := FDXCfg.Host;
Ed.OnChange := OnDXAnyChange;
FEdDXHost := Ed;
Y := Y + ROW_H;
MakeLbl(Grp, 'Port:', GRP_PAD, Y + 4, LBL_W);
Spin := TFlatSpinEdit.Create(Self);
Spin.Parent := Grp;
Spin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(90), DpiScale(BTN_H));
Spin.Color := CLR_INPUT;
Spin.Font.Color := CLR_INPUT_TEXT;
Spin.Font.Size := 9;
Spin.MinValue := 1;
Spin.MaxValue := 65535;
Spin.Value := FDXCfg.Port;
Spin.OnChange := OnDXAnyChange;
FEdDXPort := Spin;
Y := Y + ROW_H;
MakeLbl(Grp, 'Callsign:', GRP_PAD, Y + 4, LBL_W);
Ed := TFlatEdit.Create(Self);
Ed.Parent := Grp;
Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(140), DpiScale(BTN_H));
Ed.Color := CLR_INPUT;
Ed.Font.Color := CLR_INPUT_TEXT;
Ed.Font.Size := 9;
Ed.OnChange := OnDXAnyChange;
FEdDXLogin := Ed;
Lbl := MakeLbl(Grp, 'used to log in; spots for this call are highlighted',
GRP_PAD + LBL_W + 12 + 150, Y + 4, 340);
Lbl.Font.Size := 8;
Y := Y + ROW_H;
MakeLbl(Grp, 'Password:', GRP_PAD, Y + 4, LBL_W);
Ed := TFlatEdit.Create(Self);
Ed.Parent := Grp;
Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(140), DpiScale(BTN_H));
Ed.Color := CLR_INPUT;
Ed.Font.Color := CLR_INPUT_TEXT;
Ed.Font.Size := 9;
Ed.PasswordChar := '*';
Ed.OnChange := OnDXAnyChange;
FEdDXPass := Ed;
Lbl := MakeLbl(Grp, 'usually not needed', GRP_PAD + LBL_W + 12 + 150, Y + 4, 200);
Lbl.Font.Size := 8;
Y := Y + ROW_H;
MakeLbl(Grp, 'After login:', GRP_PAD, Y + 4, LBL_W);
FEdDXPostLogin := TFlatMemo.Create(Self);
FEdDXPostLogin.Parent := Grp;
FEdDXPostLogin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y),
DpiScale(ED_W + 120), DpiScale(70));
FEdDXPostLogin.Color := CLR_INPUT;
FEdDXPostLogin.Font.Color := CLR_INPUT_TEXT;
FEdDXPostLogin.Font.Size := 9;
FEdDXPostLogin.WordWrap := False;
FEdDXPostLogin.OnChange := OnDXAnyChange;
Lbl := MakeLbl(Grp, 'one command per line (set/filter, sh/dx …) — dialects differ between clusters',
GRP_PAD, Y + 76, 520);
Lbl.Font.Size := 8;
// ── Отображение ───────────────────────────────────────────────────────────
Grp := MakeGroupPanel(FPageDXCluster, 'Spots on panadapter', MARGIN, 468,
SETTINGS_CARD_W, 292);
Y := R1;
Chk := TFlatCheckBox.Create(Self);
Chk.Parent := Grp;
Chk.Caption := 'Show spots on the spectrum';
Chk.SetBounds(DpiScale(GRP_PAD), DpiScale(Y), DpiScale(300), DpiScale(22));
Chk.Font.Size := 9;
Chk.Font.Color := CLR_TEXT;
Chk.OnChange := OnDXAnyChange;
FChkDXShow := Chk;
Y := Y + ROW_H;
MakeLbl(Grp, 'Keep for, min:', GRP_PAD, Y + 4, LBL_W);
Spin := TFlatSpinEdit.Create(Self);
Spin.Parent := Grp;
Spin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(90), DpiScale(BTN_H));
Spin.Color := CLR_INPUT;
Spin.Font.Color := CLR_INPUT_TEXT;
Spin.Font.Size := 9;
Spin.MinValue := 1;
Spin.MaxValue := 1440;
Spin.Value := FDXCfg.TTLMinutes;
Spin.OnChange := OnDXAnyChange;
FEdDXTTL := Spin;
Lbl := MakeLbl(Grp, 'also the fade horizon: a spot dims and then disappears',
GRP_PAD + LBL_W + 114, Y + 4, 360);
Lbl.Font.Size := 8;
Y := Y + ROW_H;
MakeLbl(Grp, 'Label rows:', GRP_PAD, Y + 4, LBL_W);
Spin := TFlatSpinEdit.Create(Self);
Spin.Parent := Grp;
Spin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(90), DpiScale(BTN_H));
Spin.Color := CLR_INPUT;
Spin.Font.Color := CLR_INPUT_TEXT;
Spin.Font.Size := 9;
Spin.MinValue := 1;
Spin.MaxValue := 6;
Spin.Value := FDXCfg.MaxRows;
Spin.OnChange := OnDXAnyChange;
FEdDXRows := Spin;
Lbl := MakeLbl(Grp, 'ladder height: more rows means fewer hidden spots',
GRP_PAD + LBL_W + 114, Y + 4, 360);
Lbl.Font.Size := 8;
Y := Y + ROW_H;
MakeLbl(Grp, 'Modes:', GRP_PAD, Y + 4, LBL_W);
CX := GRP_PAD + LBL_W + 12;
CY := Y;
for i := 0 to High(FChkDXMode) do
begin
Chk := TFlatCheckBox.Create(Self);
Chk.Parent := Grp;
Chk.Caption := MODE_CAPS[i];
Chk.SetBounds(DpiScale(CX), DpiScale(CY), DpiScale(90), DpiScale(22));
Chk.Font.Size := 9;
Chk.Font.Color := CLR_TEXT;
Chk.OnChange := OnDXAnyChange;
FChkDXMode[i] := Chk;
CX := CX + 96;
if CX > 480 then begin CX := GRP_PAD + LBL_W + 12; CY := CY + 26; end;
end;
Lbl := MakeLbl(Grp, 'none checked — every spot is shown',
GRP_PAD + LBL_W + 12, CY + 28, 360);
Lbl.Font.Size := 8;
end;
procedure TSettingsForm.OnDXAnyChange(Sender: TObject);
var i: Integer;
begin
if FLoading then Exit;
if not Assigned(FOnDXClusterChange) then Exit;
FDXCfg.Enabled := FChkDXEnabled.Checked;
FDXCfg.ShowSpots := FChkDXShow.Checked;
FDXCfg.Host := Trim(FEdDXHost.Text);
FDXCfg.Port := FEdDXPort.Value;
FDXCfg.Login := Trim(FEdDXLogin.Text);
FDXCfg.Password := FEdDXPass.Text;
FDXCfg.PostLogin := FEdDXPostLogin.Text;
FDXCfg.TTLMinutes := FEdDXTTL.Value;
FDXCfg.MaxRows := FEdDXRows.Value;
FDXCfg.ModeMask := 0;
for i := 0 to High(FChkDXMode) do
if (FChkDXMode[i] <> nil) and FChkDXMode[i].Checked then
FDXCfg.ModeMask := FDXCfg.ModeMask or (LongWord(1) shl i);
FOnDXClusterChange(FDXCfg);
end;
procedure TSettingsForm.LoadDXClusterSettings(const D: TDXClusterSettings);
var i: Integer;
begin
FLoading := True;
try
FDXCfg := D;
FChkDXEnabled.Checked := D.Enabled;
FChkDXShow.Checked := D.ShowSpots;
FEdDXHost.Text := D.Host;
FEdDXPort.Value := EnsureRange(D.Port, 1, 65535);
FEdDXLogin.Text := D.Login;
FEdDXPass.Text := D.Password;
FEdDXPostLogin.Text := D.PostLogin;
FEdDXTTL.Value := EnsureRange(D.TTLMinutes, 1, 1440);
FEdDXRows.Value := EnsureRange(D.MaxRows, 1, 6);
for i := 0 to High(FChkDXMode) do
if FChkDXMode[i] <> nil then
FChkDXMode[i].Checked := (D.ModeMask and (LongWord(1) shl i)) <> 0;
finally
FLoading := False;
end;
end;
procedure TSettingsForm.OnADCChkChange(Sender: TObject); procedure TSettingsForm.OnADCChkChange(Sender: TObject);
begin begin
if FLoading then Exit; if FLoading then Exit;
@@ -3540,6 +3792,11 @@ procedure TSettingsForm.ApplyTheme(const T: TAppTheme);
TFlatFloatSpinEdit(Ctrl).SetAppTheme(T) TFlatFloatSpinEdit(Ctrl).SetAppTheme(T)
else if Ctrl is TFlatRadioButton then else if Ctrl is TFlatRadioButton then
TFlatRadioButton(Ctrl).SetAppTheme(T) TFlatRadioButton(Ctrl).SetAppTheme(T)
// TFlatMemo — обёртка над TMemo со своим scrollbar: тему знает сама,
// рекурсивный обход внутрь ей только навредил бы (внутренний TMemo не
// подходит ни под одну ветку и остался бы серым).
else if Ctrl is TFlatMemo then
TFlatMemo(Ctrl).SetAppTheme(T)
else if Ctrl is TListBox then else if Ctrl is TListBox then
begin begin
TListBox(Ctrl).Color := T.BG; TListBox(Ctrl).Color := T.BG;
+109 -60
View File
@@ -20,7 +20,7 @@ interface
uses uses
Classes, SysUtils, Graphics, GraphType, ExtCtrls, Controls, Math, Classes, SysUtils, Graphics, GraphType, ExtCtrls, Controls, Math,
AppTheme, AppTheme,
AlertOverlay, SampleRateOverlay, VfoOverlay, BandPlanOverlay, AlertOverlay, SampleRateOverlay, VfoOverlay, BandPlanOverlay, DXSpotOverlay,
WaterfallView, SMeterView, RulerView, RadioModes, WaterfallView, SMeterView, RulerView, RadioModes,
Settings; // FilterEdgesFromBW — единственная таблица знака боковой Settings; // FilterEdgesFromBW — единственная таблица знака боковой
@@ -106,6 +106,7 @@ type
FVfoOverlay: TVfoOverlay; FVfoOverlay: TVfoOverlay;
FSliceOverlays: TFPList; // доп. слайс-флаги (B+); не владеет (владелец MainForm) FSliceOverlays: TFPList; // доп. слайс-флаги (B+); не владеет (владелец MainForm)
FBandPlanOverlay: TBandPlanOverlay; FBandPlanOverlay: TBandPlanOverlay;
FDXSpotOverlay: TDXSpotOverlay; // споты DX-кластера (полоса подписей + штрихи)
// ── Marker ──────────────────────────────────────────────────────────────── // ── Marker ────────────────────────────────────────────────────────────────
FMarkerActive: Boolean; FMarkerActive: Boolean;
FMarkerX: Integer; FMarkerX: Integer;
@@ -159,7 +160,7 @@ type
procedure RawHLine(Y, X1, X2: Integer; Color: TColor; procedure RawHLine(Y, X1, X2: Integer; Color: TColor;
OnPx: Integer = 0; OffPx: Integer = 0); OnPx: Integer = 0; OffPx: Integer = 0);
procedure RawFillRect(X1, Y1, X2, Y2: Integer; Color: TColor); procedure RawFillRect(X1, Y1, X2, Y2: Integer; Color: TColor);
procedure RawTriangleDown(CX, HalfW, Hgt: Integer; Color: TColor); procedure RawTriangleDown(CX, Top, HalfW, Hgt: Integer; Color: TColor);
procedure RawCurve(W: Integer; Color: TColor); procedure RawCurve(W: Integer; Color: TColor);
function GetTextMask(const S: string; FontSize: Integer; function GetTextMask(const S: string; FontSize: Integer;
Bold: Boolean): TBitmap; Bold: Boolean): TBitmap;
@@ -171,7 +172,12 @@ type
procedure DrawSliceFilterBands(W, H: Integer); procedure DrawSliceFilterBands(W, H: Integer);
procedure DrawSliceFilterLinesRaw(W, H: Integer); procedure DrawSliceFilterLinesRaw(W, H: Integer);
procedure DrawBandLetterRaw(X1, X2: Integer; L: Char; Clr: TColor); procedure DrawBandLetterRaw(X1, X2: Integer; L: Char; Clr: TColor);
// Y, с которого начинать вертикаль несущей, чтобы не перечеркнуть букву.
function CarrierTopY(X1, X2, CarrierX: Integer; L: Char): Integer;
procedure DrawBeaconMarkersRaw(W, H: Integer); procedure DrawBeaconMarkersRaw(W, H: Integer);
// Штрихи DX-спотов ниже полосы подписей. Раскладка уже посчитана оверлеем
// при пересборке кэша — здесь только N вертикальных линий.
procedure DrawDXSpotTicksRaw(W, H: Integer);
// 2TON/IMD: замер пиков по TX-буферу (общий для CPU/GL рендеров). // 2TON/IMD: замер пиков по TX-буферу (общий для CPU/GL рендеров).
// Возвращает число валидных маркеров в M (0 если измерять нечего): // Возвращает число валидных маркеров в M (0 если измерять нечего):
// [0..1] тона, [2..3] IMD3 (2f1f2, 2f2f1). Обновляет FIMDSummary. // [0..1] тона, [2..3] IMD3 (2f1f2, 2f2f1). Обновляет FIMDSummary.
@@ -179,7 +185,6 @@ type
procedure DrawIMDMarkersRaw(W, H: Integer; DBmax, InvRange: Double); procedure DrawIMDMarkersRaw(W, H: Integer; DBmax, InvRange: Double);
procedure RawCircle(CX, CY, R: Integer; Color: TColor); procedure RawCircle(CX, CY, R: Integer; Color: TColor);
procedure BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte); procedure BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte);
procedure FillBandRaw(X1, X2, H: Integer; Color: TColor);
procedure CopyGridToSpectrum(W, H: Integer); procedure CopyGridToSpectrum(W, H: Integer);
procedure DrawADCOverloadRaw(W, H: Integer); procedure DrawADCOverloadRaw(W, H: Integer);
procedure CalcFilterBandX(VfoFreq: Double; W: Integer; procedure CalcFilterBandX(VfoFreq: Double; W: Integer;
@@ -318,6 +323,7 @@ type
function SliceOverlayCount: Integer; function SliceOverlayCount: Integer;
function SliceOverlayAt(Index: Integer): TVfoOverlay; function SliceOverlayAt(Index: Integer): TVfoOverlay;
property BandPlanOverlay: TBandPlanOverlay read FBandPlanOverlay write FBandPlanOverlay; property BandPlanOverlay: TBandPlanOverlay read FBandPlanOverlay write FBandPlanOverlay;
property DXSpotOverlay: TDXSpotOverlay read FDXSpotOverlay write FDXSpotOverlay;
// ── Данные от DSP ───────────────────────────────────────────────────────── // ── Данные от DSP ─────────────────────────────────────────────────────────
procedure SetSpectrumData(const Pixels: array of Single; Count: Integer); virtual; procedure SetSpectrumData(const Pixels: array of Single; Count: Integer); virtual;
@@ -361,6 +367,9 @@ const
// Полоса фильтра передающего тракта (главный VFO при TX / передающий слайс). // Полоса фильтра передающего тракта (главный VFO при TX / передающий слайс).
// TColor = $00BBGGRR, младший байт = R (как в BlendBand/RawPack). // TColor = $00BBGGRR, младший байт = R (как в BlendBand/RawPack).
CLR_TX_BAND = TColor($003030E0); CLR_TX_BAND = TColor($003030E0);
// Кегль буквы слайса на полосе фильтра. Один на рисование буквы и на расчёт
// зазора под неё (CarrierTopY) — иначе зазор разъедется с глифом.
BAND_LETTER_FONT = 8;
// Упаковка TColor в пиксель кадра (непрозрачный). Раскладка байтов как в // Упаковка TColor в пиксель кадра (непрозрачный). Раскладка байтов как в
// BlendBand: non-Darwin = BGRA, Darwin = ARGB. // BlendBand: non-Darwin = BGRA, Darwin = ARGB.
@@ -669,6 +678,26 @@ begin
FBeaconDecHalf := HalfHz; FBeaconDecHalf := HalfHz;
end; end;
procedure TSpectrumView.DrawDXSpotTicksRaw(W, H: Integer);
// Штрих от низа полосы подписей до низа спектра — по одному на видимый спот.
// Пунктиром, чтобы не спорить с кривой сигнала и краями фильтра. Раскладка
// готова (EnsureRendered в начале кадра), здесь только N линий.
var
i, X: Integer;
Col: TColor;
begin
if (FDXSpotOverlay = nil) or (not FDXSpotOverlay.Active) then Exit;
// Низкий пан (сетка панов): полоса подписей в него не влезла и не рисуется —
// тогда и штрихам не от чего идти.
if H <= FDXSpotOverlay.BandHeight + 2 then Exit;
for i := 0 to FDXSpotOverlay.TickCount - 1 do
begin
FDXSpotOverlay.Tick(i, X, Col);
if (X < 0) or (X >= W) then Continue;
RawVLine(X, FDXSpotOverlay.BandHeight, H - 1, Col, 1, 2, 3);
end;
end;
procedure TSpectrumView.DrawBeaconMarkersRaw(W, H: Integer); procedure TSpectrumView.DrawBeaconMarkersRaw(W, H: Integer);
// Две вертикали: опорная частота маяка (зелёная пунктир) и отслеживаемый // Две вертикали: опорная частота маяка (зелёная пунктир) и отслеживаемый
// центроид (оранжевая сплошная). Расхождение видно глазом → понятно, сел ли // центроид (оранжевая сплошная). Расхождение видно глазом → понятно, сел ли
@@ -930,14 +959,16 @@ begin
end; end;
// Треугольник остриём вниз (курсор VFO): вершина основания на Y=0. // Треугольник остриём вниз (курсор VFO): вершина основания на Y=0.
procedure TSpectrumView.RawTriangleDown(CX, HalfW, Hgt: Integer; Color: TColor); procedure TSpectrumView.RawTriangleDown(CX, Top, HalfW, Hgt: Integer; Color: TColor);
// Top — верх треугольника: «голова» несущей уезжает вниз, когда её иначе
// накрыла бы буква слайса (см. CarrierTopY).
var var
Y, Half: Integer; Y, Half: Integer;
begin begin
for Y := 0 to Hgt do for Y := 0 to Hgt do
begin begin
Half := Round(HalfW * (Hgt - Y) / Hgt); Half := Round(HalfW * (Hgt - Y) / Hgt);
RawHLine(Y, CX - Half, CX + Half + 1, Color); RawHLine(Top + Y, CX - Half, CX + Half + 1, Color);
end; end;
end; end;
@@ -1136,41 +1167,6 @@ end;
// Canvas.FillRect для полосы фильтра в DrawSpectrum: полоса рисуется в // Canvas.FillRect для полосы фильтра в DrawSpectrum: полоса рисуется в
// raw-фазе (между memcpy сетки и градиентом), а FillRect дёргал бы Canvas // raw-фазе (между memcpy сетки и градиентом), а FillRect дёргал бы Canvas
// и форсил лишнюю синхронизацию битмапа на Qt6. // и форсил лишнюю синхронизацию битмапа на Qt6.
procedure TSpectrumView.FillBandRaw(X1, X2, H: Integer; Color: TColor);
var
Y, W: Integer;
Row: PLongWord;
Px: LongWord;
R, G, B: Byte;
begin
if FSpectrumBitmap = nil then Exit;
W := FSpectrumBitmap.Width;
if X1 < 0 then X1 := 0;
if X2 > W then X2 := W;
if X2 <= X1 then Exit;
R := Color and $FF; G := (Color shr 8) and $FF; B := (Color shr 16) and $FF;
{$IFDEF DARWIN}
// ARGB в памяти (см. BlendBand): байт 0 = альфа, дальше R,G,B.
Px := LongWord($FF) or (LongWord(R) shl 8) or (LongWord(G) shl 16) or
(LongWord(B) shl 24);
{$ELSE}
// BGRA в памяти: байты B,G,R,A.
Px := LongWord(B) or (LongWord(G) shl 8) or (LongWord(R) shl 16) or $FF000000;
{$ENDIF}
FSpectrumBitmap.BeginUpdate(False);
try
for Y := 0 to H - 1 do
begin
Row := PLongWord(FSpectrumBitmap.ScanLine[Y]);
if Row = nil then Continue;
Inc(Row, X1);
FillDWord(Row^, X2 - X1, Px);
end;
finally
FSpectrumBitmap.EndUpdate(False);
end;
end;
// Маркер фильтра каждого слайса (B+) на спектре — полупрозрачная полоса // Маркер фильтра каждого слайса (B+) на спектре — полупрозрачная полоса
// пропускания + края + центральная несущая. Янтарный цвет, как в GL-вьюхе // пропускания + края + центральная несущая. Янтарный цвет, как в GL-вьюхе
// (SpectrumViewOpengl.DrawSliceFilterMarkers). Полосы (raw ScanLine-блендинг) // (SpectrumViewOpengl.DrawSliceFilterMarkers). Полосы (raw ScanLine-блендинг)
@@ -1192,7 +1188,7 @@ begin
CalcSliceBandX(O, W, SX1, SX2, SVfoX); CalcSliceBandX(O, W, SX1, SX2, SVfoX);
if SX2 > SX1 then if SX2 > SX1 then
BlendBand(SX1, SX2, H, Clr and $FF, (Clr shr 8) and $FF, (Clr shr 16) and $FF, BlendBand(SX1, SX2, H, Clr and $FF, (Clr shr 8) and $FF, (Clr shr 16) and $FF,
IfThen(O.TxActive, 100, 60)); IfThen(O.TxActive, SPEC_BAND_ALPHA_TX, SPEC_BAND_ALPHA));
end; end;
end; end;
@@ -1211,7 +1207,9 @@ begin
CalcSliceBandX(O, W, SX1, SX2, SVfoX); CalcSliceBandX(O, W, SX1, SX2, SVfoX);
RawVLine(SX1, 0, H - 1, Clr); RawVLine(SX1, 0, H - 1, Clr);
RawVLine(SX2, 0, H - 1, Clr); RawVLine(SX2, 0, H - 1, Clr);
RawVLine(SVfoX, 0, H - 13, Clr, 2); // несущая (жирнее) // Несущая (жирнее). В AM/FM/DSB она приходится ровно на букву — начинаем
// под ней, чтобы буква читалась (в SSB/CW несущая у кромки, зазор = 0).
RawVLine(SVfoX, CarrierTopY(SX1, SX2, SVfoX, O.SliceLetter), H - 13, Clr, 2);
// Буква слайса внутри полосы фильтра сверху — привязка полосы к флагу, // Буква слайса внутри полосы фильтра сверху — привязка полосы к флагу,
// цвет = цвет слайса (совпадает с бейджем во флаге). // цвет = цвет слайса (совпадает с бейджем во флаге).
DrawBandLetterRaw(SX1, SX2, O.SliceLetter, SliceColor(O.SliceLetter)); DrawBandLetterRaw(SX1, SX2, O.SliceLetter, SliceColor(O.SliceLetter));
@@ -1226,7 +1224,30 @@ var
begin begin
if (L < 'A') or (L > 'Z') then Exit; if (L < 'A') or (L > 'Z') then Exit;
cx := (X1 + X2) div 2; cx := (X1 + X2) div 2;
RawText(cx - RawTextWidth(L, 8, True) div 2, 0, L, Clr, 8, True, clNone); RawText(cx - RawTextWidth(L, BAND_LETTER_FONT, True) div 2, 0, L, Clr,
BAND_LETTER_FONT, True, clNone);
end;
function TSpectrumView.CarrierTopY(X1, X2, CarrierX: Integer; L: Char): Integer;
// Вертикаль несущей идёт от самого верха полосы — и в модуляциях с несущей
// (AM/FM/DSB) она приходится ровно на букву слайса: и буква, и несущая стоят по
// центру полосы. В SSB/CW несущая лежит у кромки, пересечения нет, поэтому
// раньше это в глаза не бросалось. Здесь считаем, накрывает ли вертикаль букву,
// и если да — начинаем её ПОД буквой. L = #0 (или полоса без буквы) → 0.
const
CLEAR_X = 3; // запас по бокам от глифа
CLEAR_Y = 2; // зазор между буквой и началом вертикали
var
M: TBitmap;
cx: Integer;
begin
Result := 0;
if (L < 'A') or (L > 'Z') or (X2 <= X1) then Exit;
M := GetTextMask(L, BAND_LETTER_FONT, True);
if M = nil then Exit;
cx := (X1 + X2) div 2;
if Abs(CarrierX - cx) <= M.Width div 2 + CLEAR_X then
Result := M.Height + CLEAR_Y;
end; end;
procedure TSpectrumView.CopyGridToSpectrum(W, H: Integer); procedure TSpectrumView.CopyGridToSpectrum(W, H: Integer);
@@ -1544,7 +1565,8 @@ var
DBmin, DBmax, dB: Double; DBmin, DBmax, dB: Double;
VfoX, X1, X2: Integer; VfoX, X1, X2: Integer;
TXVfoX, TXX1, TXX2: Integer; TXVfoX, TXX1, TXX2: Integer;
AGCy, AGCHangY: Integer; AGCy, AGCHangY, CarrY: Integer;
MainLetter: Char;
SrcF, Frac, dBv: Double; SrcF, Frac, dBv: Double;
S0, S1: Integer; S0, S1: Integer;
InvRange: Double; InvRange: Double;
@@ -1705,6 +1727,10 @@ begin
FSpPts[W] := Point(W-1, H); FSpPts[W+1] := Point(0, H); FSpPts[W] := Point(W-1, H); FSpPts[W+1] := Point(0, H);
// Раскладка спотов — ДО RawBegin: штрихи в фазе raw берут из неё готовые X,
// а сама пересборка идёт на своём кэш-битмапе и только по dirty-ключу.
if Assigned(FDXSpotOverlay) then FDXSpotOverlay.EnsureRendered(W);
// ═══ Фаза 1: raw — весь кадр одним локом ═════════════════════════════════ // ═══ Фаза 1: raw — весь кадр одним локом ═════════════════════════════════
{$IFDEF DARWIN} {$IFDEF DARWIN}
// Cocoa-вариант CopyGridToSpectrum идёт через Canvas.Draw — до RawBegin. // Cocoa-вариант CopyGridToSpectrum идёт через Canvas.Draw — до RawBegin.
@@ -1727,7 +1753,14 @@ begin
end else end else
if X2 > X1 then BlendBand(X1, X2, H, $E0, $30, $30, 100); if X2 > X1 then BlendBand(X1, X2, H, $E0, $30, $30, 100);
end else end else
if X2 > X1 then FillBandRaw(X1, X2, H, FTheme.SpecFilter); // Полупрозрачная заливка тем же способом, что у слайсов (SPEC_BAND_ALPHA):
// раньше главная полоса заливалась НЕПРОЗРАЧНО и выглядела плотным блоком
// рядом с просвечивающими слайсовыми. Цвет — SpecFilterBand (свой, не
// подложка подписей AGC): см. комментарий в AppTheme.
if X2 > X1 then
BlendBand(X1, X2, H, FTheme.SpecFilterBand and $FF,
(FTheme.SpecFilterBand shr 8) and $FF,
(FTheme.SpecFilterBand shr 16) and $FF, SPEC_BAND_ALPHA);
// Полосы фильтра слайсов (B+) — под градиентом/кривой, как в GL-вьюхе. // Полосы фильтра слайсов (B+) — под градиентом/кривой, как в GL-вьюхе.
DrawSliceFilterBands(W, H); DrawSliceFilterBands(W, H);
@@ -1738,12 +1771,34 @@ begin
DrawSpectrumGradient(FSpPts, W, H); DrawSpectrumGradient(FSpPts, W, H);
// ★ Кромки, несущая и буква ГЛАВНОГО фильтра — здесь, ДО кривой спектра,
// ровно как у слайсов ниже и как в GL-вьюхе (там весь блок фильтров идёт
// перед DrawSpectrumCurve). Раньше этот кусок стоял ПОСЛЕ кривой, и на
// CPU-пути главный фильтр единственный лез поверх сигнала — с виду толще и
// ярче слайсовых, хотя цвета и толщина те же.
RawVLine(X1, 0, H - 1, FTheme.SpecFilterEdge);
RawVLine(X2, 0, H - 1, FTheme.SpecFilterEdge);
// Буква главного флага (A) на его полосе — только когда есть слайсы, // Буква главного флага (A) на его полосе — только когда есть слайсы,
// иначе одиночный приём не засоряем. // иначе одиночный приём не засоряем. Букву запоминаем: под неё
// подстраиваются вертикаль несущей и её треугольник.
MainLetter := #0;
if (FSliceOverlays <> nil) and (FSliceOverlays.Count > 0) and if (FSliceOverlays <> nil) and (FSliceOverlays.Count > 0) and
(FActiveVfo = 0) and Assigned(FVfoOverlay) then (FActiveVfo = 0) and Assigned(FVfoOverlay) then
DrawBandLetterRaw(X1, X2, FVfoOverlay.SliceLetter, MainLetter := FVfoOverlay.SliceLetter;
SliceColor(FVfoOverlay.SliceLetter)); CarrY := CarrierTopY(X1, X2, VfoX, MainLetter);
RawVLine(VfoX, CarrY, H - 13, FTheme.SpecVfoCursor, 2);
RawTriangleDown(VfoX, CarrY, 5, 8, FTheme.SpecVfoCursor);
if FTXOverlay and (FTXVfoIndex <> FActiveVfo) then
begin
RawVLine(TXX1, 0, H - 1, TColor($002030E0));
RawVLine(TXX2, 0, H - 1, TColor($002030E0));
// Буква нарисована на RX-полосе (X1..X2) — с ней и сверяемся.
CarrY := CarrierTopY(X1, X2, TXVfoX, MainLetter);
RawVLine(TXVfoX, CarrY, H - 13, TColor($002030E0), 2);
RawTriangleDown(TXVfoX, CarrY, 5, 8, TColor($002030E0));
end;
if MainLetter <> #0 then
DrawBandLetterRaw(X1, X2, MainLetter, SliceColor(MainLetter));
// Края/несущие/буквы слайсов (полосы уже нарисованы выше). // Края/несущие/буквы слайсов (полосы уже нарисованы выше).
DrawSliceFilterLinesRaw(W, H); DrawSliceFilterLinesRaw(W, H);
@@ -1779,21 +1834,12 @@ begin
// Линия спектра // Линия спектра
RawCurve(W, FTheme.SpecLine); RawCurve(W, FTheme.SpecLine);
// Края фильтра + VFO // (кромки/несущая главного фильтра нарисованы выше — до кривой)
RawVLine(X1, 0, H - 1, FTheme.SpecFilterEdge);
RawVLine(X2, 0, H - 1, FTheme.SpecFilterEdge);
RawVLine(VfoX, 0, H - 13, FTheme.SpecVfoCursor, 2);
RawTriangleDown(VfoX, 5, 8, FTheme.SpecVfoCursor);
if FTXOverlay and (FTXVfoIndex <> FActiveVfo) then
begin
RawVLine(TXX1, 0, H - 1, TColor($002030E0));
RawVLine(TXX2, 0, H - 1, TColor($002030E0));
RawVLine(TXVfoX, 0, H - 13, TColor($002030E0), 2);
RawTriangleDown(TXVfoX, 5, 8, TColor($002030E0));
end;
if FMarkerActive then DrawMarkerLineRaw(W, H); if FMarkerActive then DrawMarkerLineRaw(W, H);
if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersRaw(W, H); if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersRaw(W, H);
// Штрихи DX-спотов — под маркерами наведения, но над кривой.
DrawDXSpotTicksRaw(W, H);
// 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при // 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при
// 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре) // 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре)
if FIMDActive then DrawIMDMarkersRaw(W, H, DBmax, InvRange); if FIMDActive then DrawIMDMarkersRaw(W, H, DBmax, InvRange);
@@ -1809,6 +1855,9 @@ begin
// Бэндплан QO-100 — полоска внизу спектра, под панелями оверлеев. // Бэндплан QO-100 — полоска внизу спектра, под панелями оверлеев.
if Assigned(FBandPlanOverlay) then if Assigned(FBandPlanOverlay) then
FBandPlanOverlay.DrawOverlay(FSpectrumBitmap, W, H); FBandPlanOverlay.DrawOverlay(FSpectrumBitmap, W, H);
// Полоса подписей DX-спотов — вверху спектра, под флагами VFO.
if Assigned(FDXSpotOverlay) then
FDXSpotOverlay.DrawOverlay(FSpectrumBitmap, W, H);
if Assigned(FSampleRateOverlay) then if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H); FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H);
if Assigned(FVfoOverlay) then if Assigned(FVfoOverlay) then
+111 -11
View File
@@ -18,6 +18,7 @@ uses
Classes, SysUtils, Graphics, Controls, Math, Types, Classes, SysUtils, Graphics, Controls, Math, Types,
OpenGLContextEx, GL, OpenGLContextEx, GL,
AppTheme, AlertOverlay, SpectrumView, VfoOverlay, BandPlanOverlay, AppTheme, AlertOverlay, SpectrumView, VfoOverlay, BandPlanOverlay,
DXSpotOverlay,
WaterfallView, WaterfallViewOpengl; WaterfallView, WaterfallViewOpengl;
type type
@@ -36,6 +37,7 @@ type
FSampleOverlayDirty: Boolean; FSampleOverlayDirty: Boolean;
FVfoOverlayDirty: Boolean; FVfoOverlayDirty: Boolean;
FBandOverlayDirty: Boolean; FBandOverlayDirty: Boolean;
FDXOverlayDirty: Boolean;
FGridLabelTex: TGLTextureCache; FGridLabelTex: TGLTextureCache;
FAGCLabelTex: TGLTextureCache; FAGCLabelTex: TGLTextureCache;
FAGCHangLabelTex: TGLTextureCache; FAGCHangLabelTex: TGLTextureCache;
@@ -44,6 +46,7 @@ type
FSampleOverlayTex: TGLTextureCache; FSampleOverlayTex: TGLTextureCache;
FVfoOverlayTex: TGLTextureCache; FVfoOverlayTex: TGLTextureCache;
FBandOverlayTex: TGLTextureCache; FBandOverlayTex: TGLTextureCache;
FDXOverlayTex: TGLTextureCache;
// Текстуры флагов слайсов (B+), привязка по указателю оверлея. // Текстуры флагов слайсов (B+), привязка по указателю оверлея.
FSliceTex: array of record FSliceTex: array of record
Overlay: TVfoOverlay; Overlay: TVfoOverlay;
@@ -72,6 +75,8 @@ type
FLastBandW: Integer; FLastBandW: Integer;
FLastBandCenter: Double; FLastBandCenter: Double;
FLastBandSpan: Double; FLastBandSpan: Double;
FLastDXW: Integer;
FLastDXRender: Int64;
FGLSpectrumW: Integer; FGLSpectrumW: Integer;
FGLSpectrumH: Integer; FGLSpectrumH: Integer;
FSpY: array of Integer; FSpY: array of Integer;
@@ -93,6 +98,7 @@ type
procedure DrawSpectrumCurve(W, H: Integer; DBmax, InvRange: Double); procedure DrawSpectrumCurve(W, H: Integer; DBmax, InvRange: Double);
procedure DrawMarker(W, H: Integer); procedure DrawMarker(W, H: Integer);
procedure DrawBeaconMarkersGL(W, H: Integer); procedure DrawBeaconMarkersGL(W, H: Integer);
procedure DrawDXSpotTicksGL(W, H: Integer);
procedure DrawCircleGL(CX, CY: Integer; R: Single; C: TColor); procedure DrawCircleGL(CX, CY: Integer; R: Single; C: TColor);
procedure DrawIMDMarkersGL(W, H: Integer; DBmax, InvRange: Double); procedure DrawIMDMarkersGL(W, H: Integer; DBmax, InvRange: Double);
procedure DrawADCOverlay(W, H: Integer); procedure DrawADCOverlay(W, H: Integer);
@@ -100,6 +106,9 @@ type
procedure DrawSliceOverlays(W, H: Integer); procedure DrawSliceOverlays(W, H: Integer);
procedure DrawSliceFilterMarkers(W, H: Integer); procedure DrawSliceFilterMarkers(W, H: Integer);
procedure DrawBandLetterGL(X1, X2: Integer; L: Char); procedure DrawBandLetterGL(X1, X2: Integer; L: Char);
// Y начала вертикали несущей, чтобы она не легла на букву (двойник
// CPU-CarrierTopY; метрику берём из той же текстуры буквы).
function CarrierTopYGL(X1, X2, CarrierX: Integer; L: Char): Integer;
function SliceTexIndex(O: TVfoOverlay): Integer; // индекс в FSliceTex, -1 если нет function SliceTexIndex(O: TVfoOverlay): Integer; // индекс в FSliceTex, -1 если нет
function FormatFreqGL(Hz: Double): string; function FormatFreqGL(Hz: Double): string;
function ActiveVfoFrequency: Double; function ActiveVfoFrequency: Double;
@@ -152,8 +161,10 @@ begin
FSampleOverlayDirty := True; FSampleOverlayDirty := True;
FVfoOverlayDirty := True; FVfoOverlayDirty := True;
FBandOverlayDirty := True; FBandOverlayDirty := True;
FDXOverlayDirty := True;
FLastBandW := -MaxInt; FLastBandW := -MaxInt;
FLastBandCenter := -1; FLastBandSpan := -1; FLastBandCenter := -1; FLastBandSpan := -1;
FLastDXW := -MaxInt; FLastDXRender := -1;
FLastAGCY := -MaxInt; FLastAGCY := -MaxInt;
FLastAGCHangY := -MaxInt; FLastAGCHangY := -MaxInt;
FLastMarkerX := -MaxInt; FLastMarkerX := -MaxInt;
@@ -173,6 +184,7 @@ begin
DeleteTexture(FSampleOverlayTex); DeleteTexture(FSampleOverlayTex);
DeleteTexture(FVfoOverlayTex); DeleteTexture(FVfoOverlayTex);
DeleteTexture(FBandOverlayTex); DeleteTexture(FBandOverlayTex);
DeleteTexture(FDXOverlayTex);
for i := 0 to High(FSliceTex) do DeleteTexture(FSliceTex[i].Tex); for i := 0 to High(FSliceTex) do DeleteTexture(FSliceTex[i].Tex);
for i := 0 to High(FLetterTex) do DeleteTexture(FLetterTex[i]); for i := 0 to High(FLetterTex) do DeleteTexture(FLetterTex[i]);
for i := 0 to High(FIMDLabelTex) do DeleteTexture(FIMDLabelTex[i]); for i := 0 to High(FIMDLabelTex) do DeleteTexture(FIMDLabelTex[i]);
@@ -454,6 +466,7 @@ begin
Zap(FADCOverlayTex); Zap(FADCOverlayTex);
Zap(FSampleOverlayTex); Zap(FSampleOverlayTex);
Zap(FVfoOverlayTex); Zap(FVfoOverlayTex);
Zap(FDXOverlayTex);
Zap(FBandOverlayTex); Zap(FBandOverlayTex);
for i := 0 to High(FSliceTex) do Zap(FSliceTex[i].Tex); for i := 0 to High(FSliceTex) do Zap(FSliceTex[i].Tex);
for i := 0 to High(FLetterTex) do Zap(FLetterTex[i]); for i := 0 to High(FLetterTex) do Zap(FLetterTex[i]);
@@ -469,6 +482,7 @@ begin
FSampleOverlayDirty := True; FSampleOverlayDirty := True;
FVfoOverlayDirty := True; FVfoOverlayDirty := True;
FBandOverlayDirty := True; FBandOverlayDirty := True;
FDXOverlayDirty := True;
FGLSpectrumW := 0; FGLSpectrumW := 0;
FGLSpectrumH := 0; FGLSpectrumH := 0;
// Водопад — свой контекст/текстуры (история + маркер). При reparent (pop-out) // Водопад — свой контекст/текстуры (история + маркер). При reparent (pop-out)
@@ -581,7 +595,8 @@ end;
procedure TSpectrumViewOpenGL.DrawFilterAndCursors(W, H: Integer); procedure TSpectrumViewOpenGL.DrawFilterAndCursors(W, H: Integer);
var var
X1, X2, VfoX, TXX1, TXX2, TXVfoX: Integer; X1, X2, VfoX, TXX1, TXX2, TXVfoX, CarrY: Integer;
MainLetter: Char;
begin begin
TXX1 := 0; TXX1 := 0;
TXX2 := 0; TXX2 := 0;
@@ -607,24 +622,35 @@ begin
else if X2 > X1 then DrawRect(X1, 0, X2, H, CLR_TX_BAND, 100 / 255); else if X2 > X1 then DrawRect(X1, 0, X2, H, CLR_TX_BAND, 100 / 255);
end end
else if X2 > X1 then else if X2 > X1 then
DrawRect(X1, 0, X2, H, FTheme.SpecFilter, 1); // Как у слайсов: полупрозрачная заливка (раньше здесь была alpha=1, и
// главная полоса выглядела плотным блоком рядом с просвечивающими).
// Цвет — SpecFilterBand, свой у полосы (см. AppTheme).
DrawRect(X1, 0, X2, H, FTheme.SpecFilterBand, SPEC_BAND_ALPHA / 255);
// Буква главного флага (A) на его полосе — только когда есть слайсы. Саму
// букву рисуем ниже, но знать о ней надо здесь: под неё уходит начало
// вертикали несущей и её «шляпка».
MainLetter := #0;
if (SliceOverlayCount > 0) and (FActiveVfo = 0) and Assigned(FVfoOverlay) then
MainLetter := FVfoOverlay.SliceLetter;
DrawLine(X1, 0, X1, H, FTheme.SpecFilterEdge, 1, False); DrawLine(X1, 0, X1, H, FTheme.SpecFilterEdge, 1, False);
DrawLine(X2, 0, X2, H, FTheme.SpecFilterEdge, 1, False); DrawLine(X2, 0, X2, H, FTheme.SpecFilterEdge, 1, False);
DrawLine(VfoX, 0, VfoX, H - 12, FTheme.SpecVfoCursor, 2, False); CarrY := CarrierTopYGL(X1, X2, VfoX, MainLetter);
DrawRect(VfoX - 5, 0, VfoX + 5, 8, FTheme.SpecVfoCursor, 1); DrawLine(VfoX, CarrY, VfoX, H - 12, FTheme.SpecVfoCursor, 2, False);
DrawRect(VfoX - 5, CarrY, VfoX + 5, CarrY + 8, FTheme.SpecVfoCursor, 1);
if FTXOverlay and (FTXVfoIndex <> FActiveVfo) then if FTXOverlay and (FTXVfoIndex <> FActiveVfo) then
begin begin
DrawLine(TXX1, 0, TXX1, H, TColor($002030E0), 1, False); DrawLine(TXX1, 0, TXX1, H, TColor($002030E0), 1, False);
DrawLine(TXX2, 0, TXX2, H, TColor($002030E0), 1, False); DrawLine(TXX2, 0, TXX2, H, TColor($002030E0), 1, False);
DrawLine(TXVfoX, 0, TXVfoX, H - 12, TColor($002030E0), 2, False); // Буква нарисована на RX-полосе (X1..X2) — с ней и сверяемся.
DrawRect(TXVfoX - 5, 0, TXVfoX + 5, 8, TColor($002030E0), 1); CarrY := CarrierTopYGL(X1, X2, TXVfoX, MainLetter);
DrawLine(TXVfoX, CarrY, TXVfoX, H - 12, TColor($002030E0), 2, False);
DrawRect(TXVfoX - 5, CarrY, TXVfoX + 5, CarrY + 8, TColor($002030E0), 1);
end; end;
// Буква главного флага (A) на его полосе — только когда есть слайсы. if MainLetter <> #0 then DrawBandLetterGL(X1, X2, MainLetter);
if (SliceOverlayCount > 0) and (FActiveVfo = 0) and Assigned(FVfoOverlay) then
DrawBandLetterGL(X1, X2, FVfoOverlay.SliceLetter);
DrawSliceFilterMarkers(W, H); DrawSliceFilterMarkers(W, H);
end; end;
@@ -646,10 +672,13 @@ begin
else Clr := SliceColor(O.SliceLetter); else Clr := SliceColor(O.SliceLetter);
CalcSliceBandX(O, W, SX1, SX2, SVfoX); CalcSliceBandX(O, W, SX1, SX2, SVfoX);
if SX2 > SX1 then if SX2 > SX1 then
DrawRect(SX1, 0, SX2, H, Clr, IfThen(O.TxActive, 100, 60) / 255); DrawRect(SX1, 0, SX2, H, Clr,
IfThen(O.TxActive, SPEC_BAND_ALPHA_TX, SPEC_BAND_ALPHA) / 255);
DrawLine(SX1, 0, SX1, H, Clr, 1, False); DrawLine(SX1, 0, SX1, H, Clr, 1, False);
DrawLine(SX2, 0, SX2, H, Clr, 1, False); DrawLine(SX2, 0, SX2, H, Clr, 1, False);
DrawLine(SVfoX, 0, SVfoX, H - 12, Clr, 2, False); // Несущая — под буквой, если она приходится на неё (AM/FM/DSB).
DrawLine(SVfoX, CarrierTopYGL(SX1, SX2, SVfoX, O.SliceLetter),
SVfoX, H - 12, Clr, 2, False);
DrawBandLetterGL(SX1, SX2, O.SliceLetter); DrawBandLetterGL(SX1, SX2, O.SliceLetter);
end; end;
end; end;
@@ -668,6 +697,29 @@ begin
DrawTexture(FLetterTex[idx], cx - FLetterTex[idx].W div 2, 0); DrawTexture(FLetterTex[idx], cx - FLetterTex[idx].W div 2, 0);
end; end;
function TSpectrumViewOpenGL.CarrierTopYGL(X1, X2, CarrierX: Integer;
L: Char): Integer;
// То же правило, что в CPU-пути: в модуляциях с несущей (AM/FM/DSB) вертикаль
// приходится ровно на букву — начинаем её ПОД буквой; в SSB/CW несущая у кромки
// полосы, пересечения нет и зазор нулевой. Текстуру буквы при необходимости
// заливаем здесь же: она нужна нам раньше, чем до неё дойдёт DrawBandLetterGL
// (порядок вызовов в кадре — линии, потом буквы).
const
CLEAR_X = 3;
CLEAR_Y = 2;
var
idx: Integer;
begin
Result := 0;
if (L < 'A') or (L > 'H') or (X2 <= X1) then Exit;
idx := Ord(L) - Ord('A');
if FLetterTex[idx].Tex = 0 then
UploadText(FLetterTex[idx], L, SliceColor(L), 8, True);
if FLetterTex[idx].H <= 0 then Exit;
if Abs(CarrierX - (X1 + X2) div 2) <= FLetterTex[idx].W div 2 + CLEAR_X then
Result := FLetterTex[idx].H + CLEAR_Y;
end;
procedure TSpectrumViewOpenGL.DrawAGCLines(W, H: Integer; DBmax, procedure TSpectrumViewOpenGL.DrawAGCLines(W, H: Integer; DBmax,
InvRange: Double); InvRange: Double);
var var
@@ -820,6 +872,28 @@ begin
end; end;
end; end;
procedure TSpectrumViewOpenGL.DrawDXSpotTicksGL(W, H: Integer);
// GL-двойник DrawDXSpotTicksRaw: те же X и цвета из раскладки оверлея, только
// линиями GL. EnsureRendered здесь и держит раскладку свежей — текстуру полосы
// DrawCachedOverlays перезальёт по RenderVersion, кто бы ни пересобрал кэш.
var
i, X, Y0: Integer;
Col: TColor;
begin
if (FDXSpotOverlay = nil) or (not FDXSpotOverlay.Active) then Exit;
FDXSpotOverlay.EnsureRendered(W);
Y0 := FDXSpotOverlay.BandHeight;
// Низкий пан: полоса подписей не влезла (её текстуру DrawCachedOverlays тоже
// пропускает) — штрихам не от чего идти.
if H <= Y0 + 2 then Exit;
for i := 0 to FDXSpotOverlay.TickCount - 1 do
begin
FDXSpotOverlay.Tick(i, X, Col);
if (X < 0) or (X >= W) then Continue;
DrawLine(X, Y0, X, H, Col, 1, True); // пунктир — как в CPU-пути
end;
end;
procedure TSpectrumViewOpenGL.DrawCircleGL(CX, CY: Integer; R: Single; C: TColor); procedure TSpectrumViewOpenGL.DrawCircleGL(CX, CY: Integer; R: Single; C: TColor);
// Залитый кружок (triangle fan, 20 сегментов) — маркер пика 2TON/IMD. // Залитый кружок (triangle fan, 20 сегментов) — маркер пика 2TON/IMD.
var var
@@ -947,6 +1021,31 @@ begin
DrawTexture(FBandOverlayTex, 0, H - FBandOverlayTex.H); DrawTexture(FBandOverlayTex, 0, H - FBandOverlayTex.H);
end; end;
// Полоса подписей DX-спотов — вверху спектра. Тот же битмап, что в CPU-пути,
// заливается текстурой ТОЛЬКО при dirty (новый спот меняет CacheDirty
// оверлея, вид — центр/спан/ширину).
// Низкому пану полоса подписей не по росту — пропускаем её целиком (тот же
// гейт, что в CPU-пути DrawOverlay).
if Assigned(FDXSpotOverlay) and FDXSpotOverlay.Active and
(H > FDXSpotOverlay.BandHeight) then
begin
if FOverlayDirty or FDXOverlayDirty or FDXOverlayTex.Dirty or
(FLastDXW <> W) or (FLastDXRender <> FDXSpotOverlay.RenderVersion) then
begin
B := TBitmap.Create;
try
FDXSpotOverlay.DrawOverlayBitmap(B, W);
UploadBitmap(FDXOverlayTex, B, True, DXSPOT_ALPHA);
finally
B.Free;
end;
FLastDXW := W;
FLastDXRender := FDXSpotOverlay.RenderVersion;
FDXOverlayDirty := False;
end;
DrawTexture(FDXOverlayTex, 0, 0);
end;
if Assigned(FSampleRateOverlay) then if Assigned(FSampleRateOverlay) then
begin begin
OW := Min(FSampleRateOverlay.Width, W - FSampleRateOverlay.Left); OW := Min(FSampleRateOverlay.Width, W - FSampleRateOverlay.Left);
@@ -1098,6 +1197,7 @@ begin
DrawSpectrumCurve(W, H, DBmax, InvRange); DrawSpectrumCurve(W, H, DBmax, InvRange);
DrawMarker(W, H); DrawMarker(W, H);
if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersGL(W, H); if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersGL(W, H);
DrawDXSpotTicksGL(W, H);
// 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при // 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при
// 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре) // 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре)
if FIMDActive then DrawIMDMarkersGL(W, H, DBmax, InvRange); if FIMDActive then DrawIMDMarkersGL(W, H, DBmax, InvRange);
+16 -1
View File
@@ -38,7 +38,11 @@ const
// бейджа-карточки во флаге и для буквы на полосе фильтра в спектре. // бейджа-карточки во флаге и для буквы на полосе фильтра в спектре.
SLICE_COLORS: array[0..7] of TColor = ( SLICE_COLORS: array[0..7] of TColor = (
TColor($0060E040), // A — зелёный TColor($0060E040), // A — зелёный
TColor($0010A8FF), // B оранжевый // B был оранжевым и спорил с янтарной полосой главного фильтра
// (AppTheme.SpecFilterBand). Васильковый выбран по свободному месту в круге
// оттенков: занято 0°(TX)/31°(полоса)/38/55/132/190/195/276/308, самый
// широкий незанятый промежуток — 195..276°, середина ≈236°. RGB(97,118,255).
TColor($00FF7661), // B — васильковый (indigo)
TColor($00F0D030), // C — голубой TColor($00F0D030), // C — голубой
TColor($00E070F0), // D — розовый/маджента TColor($00E070F0), // D — розовый/маджента
TColor($0030E0F0), // E — жёлтый TColor($0030E0F0), // E — жёлтый
@@ -46,6 +50,11 @@ const
TColor($00E0C060), // G — бирюзовый TColor($00E0C060), // G — бирюзовый
TColor($00C0C0C0)); // H — серый TColor($00C0C0C0)); // H — серый
// Прозрачность заливки полосы фильтра на спектре — одна на главный VFO и на
// слайсы, чтобы полосы читались одинаково (и в CPU-, и в GL-вьюхе).
SPEC_BAND_ALPHA = 60; // приём
SPEC_BAND_ALPHA_TX = 100; // передающая полоса — плотнее
// Цвет слайса по его букве (A..). За пределами таблицы — по кругу. // Цвет слайса по его букве (A..). За пределами таблицы — по кругу.
function SliceColor(L: Char): TColor; function SliceColor(L: Char): TColor;
@@ -283,6 +292,12 @@ type
property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate; property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate;
end; end;
{ BlendBitmapKey общий keyed-композит кэш-битмапа оверлея в кадр (дворд-
блендинг, магента = прозрачность). Живёт здесь исторически; вынесен в
interface, чтобы другие оверлеи той же модели (DXSpotOverlay) не заводили
вторую копию этого же цикла. }
procedure BlendBitmapKey(Target, Source: TBitmap; DstX, DstY: Integer; Alpha: Byte);
implementation implementation
function SliceColor(L: Char): TColor; function SliceColor(L: Char): TColor;
+20
View File
@@ -59,6 +59,11 @@ procedure SockSetNonBlock(S: TSocket; NB: Boolean);
если буфер клиента переполнен, держа при этом FClientLock и блокируя Stop(). } если буфер клиента переполнен, держа при этом FClientLock и блокируя Stop(). }
procedure SockSetSndTimeout(S: TSocket; Ms: Integer); procedure SockSetSndTimeout(S: TSocket; Ms: Integer);
{ SockSetRcvTimeout ограничивает время блокирующего SockRecv. Нужен клиентам,
которым между пакетами надо просыпаться самим (проверить Terminated, отдать
накопившиеся команды): recv возвращает -1 по таймауту, соединение живо. }
procedure SockSetRcvTimeout(S: TSocket; Ms: Integer);
{ ── SHA-1 ─────────────────────────────────────────────────────────────────── } { ── SHA-1 ─────────────────────────────────────────────────────────────────── }
type type
@@ -123,6 +128,13 @@ begin
setsockopt(S, SOL_SOCKET, SO_SNDTIMEO, @T, SizeOf(T)); setsockopt(S, SOL_SOCKET, SO_SNDTIMEO, @T, SizeOf(T));
end; end;
procedure SockSetRcvTimeout(S: TSocket; Ms: Integer);
var T: DWORD;
begin
T := Ms;
setsockopt(S, SOL_SOCKET, SO_RCVTIMEO, @T, SizeOf(T));
end;
{$ELSE} {$ELSE}
function SockClose(S: TSocket): Integer; function SockClose(S: TSocket): Integer;
@@ -165,6 +177,14 @@ begin
fpSetSockOpt(S, SOL_SOCKET, SO_SNDTIMEO, @TV, SizeOf(TV)); fpSetSockOpt(S, SOL_SOCKET, SO_SNDTIMEO, @TV, SizeOf(TV));
end; end;
procedure SockSetRcvTimeout(S: TSocket; Ms: Integer);
var TV: TTimeVal;
begin
TV.tv_sec := Ms div 1000;
TV.tv_usec := (Ms mod 1000) * 1000;
fpSetSockOpt(S, SOL_SOCKET, SO_RCVTIMEO, @TV, SizeOf(TV));
end;
{$ENDIF} {$ENDIF}
{ ═══════════════════════════════════════════════════════════════════════════ { ═══════════════════════════════════════════════════════════════════════════
+4 -2
View File
@@ -17,9 +17,9 @@
<UseVersionInfo Value="True"/> <UseVersionInfo Value="True"/>
<AutoIncrementBuild Value="True"/> <AutoIncrementBuild Value="True"/>
<MinorVersionNr Value="9"/> <MinorVersionNr Value="9"/>
<BuildNr Value="294"/> <BuildNr Value="295"/>
</VersionInfo> </VersionInfo>
<MacroValues Count="127"> <MacroValues Count="129">
<Macro1 Name="LCLWidgetType" Value="qt6"/> <Macro1 Name="LCLWidgetType" Value="qt6"/>
<Macro2 Name="LCLWidgetType" Value="qt6"/> <Macro2 Name="LCLWidgetType" Value="qt6"/>
<Macro3 Name="LCLWidgetType" Value="qt6"/> <Macro3 Name="LCLWidgetType" Value="qt6"/>
@@ -147,6 +147,8 @@
<Macro125 Name="LCLWidgetType" Value="qt6"/> <Macro125 Name="LCLWidgetType" Value="qt6"/>
<Macro126 Name="LCLWidgetType" Value="qt6"/> <Macro126 Name="LCLWidgetType" Value="qt6"/>
<Macro127 Name="LCLWidgetType" Value="qt6"/> <Macro127 Name="LCLWidgetType" Value="qt6"/>
<Macro128 Name="LCLWidgetType" Value="qt6"/>
<Macro129 Name="LCLWidgetType" Value="qt6"/>
</MacroValues> </MacroValues>
<BuildModes> <BuildModes>
<Item Name="Debug" Default="True"/> <Item Name="Debug" Default="True"/>