mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 17:27:32 +00:00
Merge feature/dxcluster-spots: споты DX-кластера на панадаптерах, мода по бэндплану, кнопка DX в RX
This commit is contained in:
+14
-1
@@ -54,6 +54,12 @@ type
|
||||
// Спектр — сигнал и маркеры
|
||||
SpecLine: TColor;
|
||||
SpecFilter: TColor;
|
||||
// Заливка полосы главного фильтра. Отдельно от SpecFilter (тот остался
|
||||
// подложкой подписей AGC): полоса теперь полупрозрачная, как у слайсов, и
|
||||
// цвет ей нужен СВОЙ — SpecFilter почти совпадает с нижним цветом фонового
|
||||
// градиента спектра, поэтому такая заливка читалась не оттенком, а
|
||||
// затемнением.
|
||||
SpecFilterBand: TColor;
|
||||
SpecFilterEdge: TColor;
|
||||
SpecAgcColor: TColor;
|
||||
SpecAgcHangColor: TColor;
|
||||
@@ -158,6 +164,10 @@ begin
|
||||
Result.SpecGrid := TColor($00685624);
|
||||
Result.SpecLine := TColor($0040FF80);
|
||||
Result.SpecFilter := TColor($006A4E22);
|
||||
// Оттенок полосы — тёплый янтарный, из семейства курсора VFO (Amber): фон
|
||||
// спектра сине-стальной, поэтому янтарь на нём читается как оттенок, а не
|
||||
// как «потемнее фона». RGB(255,150,40).
|
||||
Result.SpecFilterBand := TColor($002896FF);
|
||||
Result.SpecFilterEdge := TColor($0040FFCC);
|
||||
Result.SpecAgcColor := TColor($0000AAFF);
|
||||
Result.SpecAgcHangColor := TColor($00FFCC00);
|
||||
@@ -266,7 +276,10 @@ begin
|
||||
Result.SpecLabelText := TColor($00404038);
|
||||
Result.SpecGrid := TColor($00B8B4A4);
|
||||
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.SpecAgcColor := TColor($00904000); // тёмно-оранжевый AGC
|
||||
Result.SpecAgcHangColor := TColor($00802000);
|
||||
|
||||
+1506
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -69,6 +69,10 @@ type
|
||||
property Visible;
|
||||
property OnClick;
|
||||
property OnDblClick;
|
||||
// Клавиатура: собственный KeyDown обрабатывает стрелки/PgUp/Home, а
|
||||
// необработанные клавиши (Enter и прочее) достаются владельцу через
|
||||
// inherited — поэтому событие имеет смысл публиковать.
|
||||
property OnKeyDown;
|
||||
end;
|
||||
|
||||
implementation
|
||||
|
||||
@@ -23,6 +23,7 @@ type
|
||||
FScrollBar: TOverlayScrollBar;
|
||||
FTheme: TAppTheme;
|
||||
FSyncing: Boolean;
|
||||
FOnChange: TNotifyEvent;
|
||||
function GetLines: TStrings;
|
||||
function GetReadOnly: Boolean;
|
||||
procedure SetReadOnly(AValue: Boolean);
|
||||
@@ -65,6 +66,8 @@ type
|
||||
property TabOrder;
|
||||
property TabStop;
|
||||
property Visible;
|
||||
// Правка текста пользователем (проброс OnChange внутреннего TMemo).
|
||||
property OnChange: TNotifyEvent read FOnChange write FOnChange;
|
||||
end;
|
||||
|
||||
implementation
|
||||
@@ -192,6 +195,7 @@ end;
|
||||
procedure TFlatMemo.MemoChange(Sender: TObject);
|
||||
begin
|
||||
UpdateScrollBar;
|
||||
if Assigned(FOnChange) then FOnChange(Self);
|
||||
end;
|
||||
|
||||
procedure TFlatMemo.MemoKeyUp(Sender: TObject; var Key: Word;
|
||||
|
||||
+402
-11
@@ -22,8 +22,9 @@ unit MainForm;
|
||||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, FilterPopup,
|
||||
Classes, SysUtils, StrUtils, DateUtils, FreqDisplay, DeviceForm, FilterPopup,
|
||||
VfoOverlay, SampleRateOverlay, BandPlanOverlay,
|
||||
DXSpotStore, DXClusterClient, DXSpotOverlay, DXClusterForm,
|
||||
FlatButton, FlatSlider, FlatDropDown, OverlayScrollBar, AppTheme,
|
||||
Forms, Controls, Graphics, Dialogs,
|
||||
StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs,
|
||||
@@ -139,7 +140,18 @@ type
|
||||
FSliceDrag: Boolean; // тащим маркер несущей слайса по спектру
|
||||
FSliceDragId: Integer; // какой слайс тащим
|
||||
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 = левая панель скрыта
|
||||
FLeftPanelW: Integer; // ширина панели до скрытия (DPI-safe restore)
|
||||
FPendingBoardType: Integer; // BoardType устройства для подключения
|
||||
@@ -360,6 +372,8 @@ type
|
||||
LblAtt: TLabel;
|
||||
LblAttVal: TLabel;
|
||||
TrkAtt: TFlatSlider;
|
||||
// Ряд CTUN | DX | Channel | BEACON.
|
||||
BtnDXCluster: TFlatButton; // ЛКМ — споты на спектре, ПКМ — окно кластера
|
||||
BtnChannels: TFlatButton;
|
||||
FChannelsDropDown: TFlatDropDown;
|
||||
// TX-профиль: кнопка с именем активного (блок TX, ряд DUP) + список.
|
||||
@@ -497,6 +511,22 @@ type
|
||||
procedure BtnRxMuteClick(Sender: TObject);
|
||||
procedure UpdateRxMuteButton; // видимость (Pluto+full-duplex) + стиль кнопки RX MUTE
|
||||
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 ApplyTUN(Active: Boolean);
|
||||
// PureSignal: ЛКМ по PS = вкл/выкл, ПКМ = поповер настроек.
|
||||
@@ -1357,9 +1387,14 @@ begin
|
||||
end;
|
||||
|
||||
procedure TMainForm.FormDestroy(Sender: TObject);
|
||||
var i: Integer;
|
||||
begin
|
||||
FMeterTimer.Enabled := False;
|
||||
FSpectrumTimer.Enabled := False;
|
||||
// Поток кластера будим ПЕРВЫМ делом, не дожидаясь его: пока идёт (небыстрая)
|
||||
// разборка радио, он успевает выйти сам, и join в FreeAndNil(FDXClient) ниже
|
||||
// уже никого не ждёт. Иначе окно висело бы на выходе.
|
||||
if FDXClient <> nil then FDXClient.RequestStop;
|
||||
// Грациозный стоп радио (Run=0) до освобождения; сами движки закрывает и
|
||||
// освобождает TRadioController.Destroy (FreeEngines) ниже.
|
||||
if FController.FNetwork.Running then
|
||||
@@ -1373,6 +1408,22 @@ begin
|
||||
PanelSplitter := nil;
|
||||
PbWaterfall := 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
|
||||
FreeAndNil(FPan);
|
||||
FPans[0] := nil;
|
||||
@@ -1976,6 +2027,8 @@ begin
|
||||
BtnDiscover := MakeBtn(PanelToolbar, 'DISCOVER', X, 3, 80, BTN_H, BtnDiscoverClick);
|
||||
BtnStartStop := MakeBtn(PanelToolbar, 'START', X+84, 3, 76, BTN_H, BtnStartStopClick);
|
||||
BtnSettings := MakeBtn(PanelToolbar, 'SETUP', X+164, 3, 66, BTN_H, BtnSettingsClick);
|
||||
// DX живёт в блоке RX левой панели, рядом с CTUN (см. ниже): споты — часть
|
||||
// приёмного вида, а не глобальная команда уровня DISCOVER/START/SETUP.
|
||||
|
||||
// DISCOVER — синеватый акцент
|
||||
BtnDiscover.ClrNorm := TColor($00101828);
|
||||
@@ -2231,7 +2284,7 @@ begin
|
||||
|
||||
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.Parent := PanelLeft;
|
||||
PanelRXBlock.SetBounds(0, Y, LEFT_W, RX_BLOCK_H_ATT);
|
||||
@@ -2239,15 +2292,23 @@ begin
|
||||
MakeLbl(PanelRXBlock, 'RX', 4, 2);
|
||||
|
||||
BtnCTun := MakeBtn(PanelRXBlock, 'CTUN',
|
||||
LeftPanelButtonLeft(LEFT_W, 3, 0), 20,
|
||||
LeftPanelButtonWidth(LEFT_W, 3, 0),
|
||||
LeftPanelButtonLeft(LEFT_W, 4, 0), 20,
|
||||
LeftPanelButtonWidth(LEFT_W, 4, 0),
|
||||
BTN_H, BtnCTunClick);
|
||||
BtnCTun.Tag := 0;
|
||||
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',
|
||||
LeftPanelButtonLeft(LEFT_W, 3, 1), 20,
|
||||
LeftPanelButtonWidth(LEFT_W, 3, 1),
|
||||
LeftPanelButtonLeft(LEFT_W, 4, 2), 20,
|
||||
LeftPanelButtonWidth(LEFT_W, 4, 2),
|
||||
BTN_H, BtnChannelsClick);
|
||||
BtnChannels.OnMouseDown := BtnChannelsMouseDown;
|
||||
StyleButton(BtnChannels, False);
|
||||
@@ -2257,11 +2318,11 @@ begin
|
||||
FChannelsDropDown.OnSelect := OnChannelsDropDownSelect;
|
||||
RefreshChannelsDropDown;
|
||||
|
||||
// QO-100 beacon lock — 3-я колонка ряда. Видима только в XVTR-режиме
|
||||
// QO-100 beacon lock — 4-я колонка ряда. Видима только в XVTR-режиме
|
||||
// (см. рендер rfXvtr ниже). Подпись/подсветка обновляются по rfBeaconLock.
|
||||
BtnBeacon := MakeBtn(PanelRXBlock, 'BEACON',
|
||||
LeftPanelButtonLeft(LEFT_W, 3, 2), 20,
|
||||
LeftPanelButtonWidth(LEFT_W, 3, 2),
|
||||
LeftPanelButtonLeft(LEFT_W, 4, 3), 20,
|
||||
LeftPanelButtonWidth(LEFT_W, 4, 3),
|
||||
BTN_H, BtnBeaconClick);
|
||||
BtnBeacon.Visible := False;
|
||||
BtnBeacon.OnMouseDown := BtnBeaconMouseDown; // ПКМ → декодер + окно констелляции
|
||||
@@ -2641,6 +2702,9 @@ begin
|
||||
FBandPlanOverlay.SetView(FController.FCenterFreq, FController.FSpanHz);
|
||||
FSpecView.BandPlanOverlay := FBandPlanOverlay;
|
||||
|
||||
// DX-кластер: база спотов + сетевой поток + оверлей подписей.
|
||||
InitDXCluster;
|
||||
|
||||
// S-метр — внутри PanelToolbar, справа
|
||||
PanelSMeterRight := TPanel.Create(Self);
|
||||
PanelSMeterRight.Parent := PanelToolbar;
|
||||
@@ -3316,6 +3380,8 @@ begin
|
||||
StyleButton(Btn2Ton, Btn2Ton.Active);
|
||||
StyleButton(BtnRxMute, BtnRxMute.Active);
|
||||
StyleButton(BtnBeacon, BtnBeacon.Active);
|
||||
// DX — тумблер подписей спотов, состояние берём из самой кнопки (сосед CTUN).
|
||||
if BtnDXCluster <> nil then StyleButton(BtnDXCluster, BtnDXCluster.Active);
|
||||
if BtnFMSQ <> nil then StyleButton(BtnFMSQ, BtnFMSQ.Active);
|
||||
if TrkFMSQ <> nil then DS(TrkFMSQ);
|
||||
if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, BtnFMCTCSS.Active);
|
||||
@@ -3337,6 +3403,9 @@ begin
|
||||
if FPans[i] <> nil then
|
||||
begin
|
||||
FPans[i].View.SetTheme(T);
|
||||
// Палитра подписей спотов парная под тему — у каждого пана свой оверлей.
|
||||
if FPans[i].DXOverlay <> nil then
|
||||
FPans[i].DXOverlay.SetLightTheme(FLightTheme);
|
||||
if FPans[i].PbSpectrum is TPaintBox then
|
||||
TPaintBox(FPans[i].PbSpectrum).Color := T.BG;
|
||||
if FPans[i].PbWaterfall is TPaintBox then
|
||||
@@ -3451,6 +3520,8 @@ begin
|
||||
if FCWMsgForm <> nil then TCWMessagesForm(FCWMsgForm).ApplyTheme(T);
|
||||
if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).ApplyTheme(T);
|
||||
if FBeaconScopeForm <> nil then FBeaconScopeForm.ApplyTheme(T);
|
||||
if FDXClusterForm <> nil then FDXClusterForm.ApplyTheme(T);
|
||||
// Оверлеи спотов перекрашены в ApplyDarkTheme — общим циклом по FPans.
|
||||
FController.FSettings.SaveTheme(V);
|
||||
FController.FSettings.Save;
|
||||
if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
|
||||
@@ -3690,6 +3761,9 @@ begin
|
||||
// отсекает неизменное → пересборки кэша на каждый вызов нет.
|
||||
if Assigned(FBandPlanOverlay) then
|
||||
FBandPlanOverlay.SetView(ViewCenter, ViewSpan);
|
||||
// Подписи спотов — тот же видимый домен, что у бэндплана.
|
||||
if Assigned(FDXSpotOverlay) then
|
||||
FDXSpotOverlay.SetView(ViewCenter, ViewSpan);
|
||||
if UpdateWidebandFrequencyView and FController.FShowWideband and (PbWideband <> nil) then
|
||||
PbWideband.Invalidate;
|
||||
UpdatePanZoomBar; // линейка пана следует за частотой/зумом (no-op если без изменений)
|
||||
@@ -3714,6 +3788,9 @@ var
|
||||
PeakDiff, MinDiff: Double;
|
||||
PeakAlpha, MinAlpha: Double;
|
||||
begin
|
||||
// Затухание спотов не зависит от того, крутится ли приём.
|
||||
ServiceDXCluster;
|
||||
|
||||
if not FController.FRunning then
|
||||
begin
|
||||
if PbSMeterRight <> nil then PbSMeterRight.Invalidate;
|
||||
@@ -4947,6 +5024,293 @@ begin
|
||||
StyleButton(BtnRxMute, FController.FRxMuteOnTx);
|
||||
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;
|
||||
// Полоска бэндплана видна только в QO-100 (Pluto + full-duplex транспондер).
|
||||
begin
|
||||
@@ -5251,7 +5615,9 @@ end;
|
||||
// Спектр
|
||||
procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
|
||||
Shift: TShiftState; X, Y: Integer);
|
||||
var VC, VS: Double;
|
||||
var
|
||||
VC, VS: Double;
|
||||
DXSpot: TDXSpot;
|
||||
begin
|
||||
if FPan.DispatchFlagsMouseDown(Button, X, Y) then
|
||||
begin
|
||||
@@ -5261,6 +5627,15 @@ begin
|
||||
if Assigned(FSampleRateOverlay) and
|
||||
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+ЛКМ по спектру — добавить софт-слайс на этой частоте (в пределах захвата).
|
||||
if (Button = mbLeft) and (ssCtrl in Shift) and (PbSpectrum.Width > 0) then
|
||||
begin
|
||||
@@ -6507,6 +6882,9 @@ begin
|
||||
FController.GetPanViewWindow(P.PanId, VC, VS);
|
||||
P.View.CenterFreq := VC;
|
||||
P.View.SpanHz := VS;
|
||||
// Подписи спотов пана следуют за его окном (SetView сам отсекает неизменное
|
||||
// → пересборки кэша на каждый кадр нет).
|
||||
if P.DXOverlay <> nil then P.DXOverlay.SetView(VC, VS);
|
||||
// Сетка каналов по шагу FM — как на главном пане, но по режиму слайсов
|
||||
// ЭТОГО пана (у доп. панов нет своего FMode).
|
||||
if FController.PanHasFMSlice(P.PanId) then
|
||||
@@ -7158,6 +7536,8 @@ begin
|
||||
P.View.VfoA := -1e15;
|
||||
P.View.VfoB := -1e15;
|
||||
P.View.TXFreq := -1e15;
|
||||
// Подписи DX-спотов: свой оверлей на общей базе, настройки/тема — общие.
|
||||
AttachPanDXSpots(P);
|
||||
|
||||
// Сплиттер над паном.
|
||||
Split := TPanel.Create(Self);
|
||||
@@ -7539,6 +7919,7 @@ var
|
||||
VC, VS: Double;
|
||||
Id, W: Integer;
|
||||
IsSpec: Boolean;
|
||||
DXSpot: TDXSpot;
|
||||
begin
|
||||
P := PanFromSender(Sender);
|
||||
if P = nil then Exit;
|
||||
@@ -7550,6 +7931,14 @@ begin
|
||||
P.ProcessPendingSliceClose;
|
||||
Exit;
|
||||
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;
|
||||
if W <= 0 then Exit;
|
||||
if (Button = mbLeft) and (ssCtrl in Shift) then
|
||||
@@ -9286,6 +9675,7 @@ begin
|
||||
SF.OnWfAGCNFChange := ApplyWfAGCNF;
|
||||
SF.OnADCChange := ApplyADCSettings;
|
||||
SF.OnWebSettingsChange := ApplyWebSettings;
|
||||
SF.OnDXClusterChange := OnDXClusterSettingsChange;
|
||||
end;
|
||||
SF := TSettingsForm(FSettingsForm);
|
||||
PushTXProfilesToSettings;
|
||||
@@ -9340,6 +9730,7 @@ begin
|
||||
SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled);
|
||||
SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass,
|
||||
FWebSpecPixels);
|
||||
SF.LoadDXClusterSettings(FDXCfg);
|
||||
SF.LoadFPS(FDisplayFPS);
|
||||
SF.LoadLightTheme(FLightTheme);
|
||||
SF.LoadFreqMhzDigits(FFreqMhzDigits);
|
||||
|
||||
@@ -35,6 +35,7 @@ uses
|
||||
LCLIntf, LCLType,
|
||||
OpenGLContextEx,
|
||||
AppTheme, WDSPEngine, RadioController, DMRDecoder, VfoOverlay,
|
||||
DXSpotStore, DXSpotOverlay,
|
||||
SpectrumView, SpectrumViewOpengl, PanZoomBar, FlatButton, DpiUtils;
|
||||
|
||||
type
|
||||
@@ -83,6 +84,10 @@ type
|
||||
FSliceFlags: array of TVfoOverlay; // флаги B+ (Owner=Self)
|
||||
FActiveFlag: TVfoOverlay; // чьё событие сейчас обрабатывается
|
||||
FPendingSliceClose: Integer; // SliceId к удалению после мыши (0=нет)
|
||||
// Подписи DX-спотов на спектре ЭТОГО пана. Экземпляр свой на каждый пан:
|
||||
// кэш полосы подписей ключуется центром/спаном/шириной, а у панов они свои
|
||||
// — общий оверлей пересобирался бы на каждый пан каждый кадр.
|
||||
FDXOverlay: TDXSpotOverlay; // Owner=Self, база спотов общая
|
||||
FMainFlagPinned: Boolean; // True = левая панель хозяина скрыта
|
||||
FMainFlagForced: Boolean; // True = есть доп. панадаптеры
|
||||
FLastSliceMeterMs: QWord; // троттлинг S-метра слайсов (~10 Гц)
|
||||
@@ -268,6 +273,14 @@ type
|
||||
// полный пересчёт (у него wideband/S-метр/оверлеи).
|
||||
property OnSplitterMoved: TNotifyEvent read FOnSplitterMoved write FOnSplitterMoved;
|
||||
|
||||
// ---- Подписи DX-спотов ----
|
||||
// Своя копия оверлея на пан; база спотов (стор) — общая, её владелец хозяин.
|
||||
// Вид (центр/спан) пана заливает хозяин, как и прочие параметры вьюхи.
|
||||
procedure AttachDXSpots(AStore: TDXSpotStore);
|
||||
// Отвязать от базы (хозяин освобождает стор раньше панов).
|
||||
procedure DetachDXSpots;
|
||||
property DXOverlay: TDXSpotOverlay read FDXOverlay;
|
||||
|
||||
// ---- Флаги слайсов ----
|
||||
// Главный флаг (слайс A) создаёт хозяин (проводка событий у него) и отдаёт
|
||||
// сюда; панель подключает его к view и включает в раскладку/диспетчер.
|
||||
@@ -1108,6 +1121,21 @@ begin
|
||||
FView.VfoOverlay := O;
|
||||
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;
|
||||
begin
|
||||
if Assigned(FActiveFlag) then Result := FActiveFlag.SliceId else Result := 0;
|
||||
|
||||
@@ -427,6 +427,25 @@ type
|
||||
end;
|
||||
|
||||
// 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
|
||||
Enabled: Boolean;
|
||||
Port: Integer; // 1..65535
|
||||
@@ -754,6 +773,9 @@ type
|
||||
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);
|
||||
// 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);
|
||||
procedure LoadWebSettings(out W: TWebSettings);
|
||||
procedure SaveWebSettings(const W: TWebSettings);
|
||||
@@ -2873,6 +2895,55 @@ begin
|
||||
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);
|
||||
begin
|
||||
W.Enabled := True;
|
||||
|
||||
+258
-1
@@ -28,7 +28,7 @@ uses
|
||||
Forms, Controls, Graphics, Dialogs,
|
||||
StdCtrls, ExtCtrls, LCLType,
|
||||
FlatButton, FlatCheckBox, FlatComboBox, FlatEdit, FlatSpinEdit, FlatFloatSpinEdit,
|
||||
FlatRadioButton,
|
||||
FlatRadioButton, FlatMemo,
|
||||
EqualizerControl, OverlayScrollBar, AudioOutput, AudioInput, AppTheme, Settings,
|
||||
BoardUtils, DpiUtils;
|
||||
|
||||
@@ -86,6 +86,10 @@ type
|
||||
TOnThemeChange = procedure(LightTheme: Boolean) of object;
|
||||
TOnWfAGCNFChange = procedure(WfAGC, WfNF: Boolean) of object;
|
||||
TOnADCChange = procedure(Dither, Random: Boolean) of object;
|
||||
// Настройки DX-кластера уезжают наружу целиком записью: полей много и
|
||||
// добавлять их по одному в сигнатуру (как у веб-сервера) уже неудобно.
|
||||
TOnDXClusterChange = procedure(const D: TDXClusterSettings) of object;
|
||||
|
||||
TOnWebSettingsChange = procedure(Enabled: Boolean; Port: Integer;
|
||||
const BindAddr, User, Pass: string; SpecPixels: Integer) of object;
|
||||
TOnCATChange = procedure(
|
||||
@@ -124,6 +128,19 @@ type
|
||||
FPageWaterfall: TScrollBox;
|
||||
FPagePA: 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;
|
||||
FPageSlices: TScrollBox;
|
||||
FPageTransmit: TScrollBox;
|
||||
@@ -133,6 +150,7 @@ type
|
||||
FNavWaterfall: TFlatButton;
|
||||
FNavPA: TFlatButton;
|
||||
FNavAdvanced: TFlatButton;
|
||||
FNavDXCluster: TFlatButton;
|
||||
FNavCAT: TFlatButton;
|
||||
FNavSlices: TFlatButton;
|
||||
FNavTransmit: TFlatButton;
|
||||
@@ -465,6 +483,7 @@ type
|
||||
FOnVHFCalChange: TOnVHFCalChange;
|
||||
FOnADCChange: TOnADCChange;
|
||||
FOnWebSettingsChange: TOnWebSettingsChange;
|
||||
FOnDXClusterChange: TOnDXClusterChange;
|
||||
FOnDisplayChange: TOnDisplayParamChange;
|
||||
FOnWaterfallChange: TOnWaterfallParamChange;
|
||||
FOnWfRenderChange: TOnWfRenderChange;
|
||||
@@ -572,6 +591,8 @@ type
|
||||
OnChange: TNotifyEvent): TFlatComboBox;
|
||||
function MakeGroupPanel(AParent: TWinControl; const Cap: string;
|
||||
ALeft, ATop, AW, AH: Integer): TPanel;
|
||||
procedure BuildDXClusterTab;
|
||||
procedure OnDXAnyChange(Sender: TObject);
|
||||
function MakeScrollPage: TScrollBox;
|
||||
procedure AddPageBottomSpace(APage: TScrollBox);
|
||||
procedure UpdatePageBottomSpace(APage: TScrollBox);
|
||||
@@ -696,6 +717,7 @@ type
|
||||
ResLimit, SpeedDiv: Integer);
|
||||
procedure LoadSpecMSAA(Samples: Integer);
|
||||
procedure LoadADCSettings(Dither, Random: Boolean);
|
||||
procedure LoadDXClusterSettings(const D: TDXClusterSettings);
|
||||
procedure LoadWebSettings(Enabled: Boolean; Port: Integer;
|
||||
const BindAddr, User, Pass: string; SpecPixels: Integer);
|
||||
|
||||
@@ -734,6 +756,7 @@ type
|
||||
property OnWfAGCNFChange: TOnWfAGCNFChange read FOnWfAGCNFChange write FOnWfAGCNFChange;
|
||||
property OnADCChange: TOnADCChange read FOnADCChange write FOnADCChange;
|
||||
property OnWebSettingsChange: TOnWebSettingsChange read FOnWebSettingsChange write FOnWebSettingsChange;
|
||||
property OnDXClusterChange: TOnDXClusterChange read FOnDXClusterChange write FOnDXClusterChange;
|
||||
end;
|
||||
|
||||
implementation
|
||||
@@ -1113,6 +1136,7 @@ begin
|
||||
FNavCAT := MakeNavButton('CAT', 496);
|
||||
FNavSlices := MakeNavButton('Slices', 528);
|
||||
FNavAdvanced := MakeNavButton('Advanced', 560);
|
||||
FNavDXCluster := MakeNavButton('DX Cluster', 592);
|
||||
|
||||
FContentPanel := TPanel.Create(Self);
|
||||
FContentPanel.Parent := Self;
|
||||
@@ -1135,6 +1159,7 @@ begin
|
||||
FPagePA := MakeScrollPage;
|
||||
FPageCalib := MakeScrollPage;
|
||||
FPageAdvanced := MakeScrollPage;
|
||||
FPageDXCluster := MakeScrollPage;
|
||||
FPageCAT := MakeScrollPage;
|
||||
FPageSlices := MakeScrollPage;
|
||||
FPageAlex := MakeScrollPage;
|
||||
@@ -1154,6 +1179,7 @@ begin
|
||||
BuildPATab;
|
||||
BuildCalibrationTab;
|
||||
BuildAdvancedTab;
|
||||
BuildDXClusterTab;
|
||||
BuildCATTab;
|
||||
BuildSlicesTab;
|
||||
BuildAlexTab;
|
||||
@@ -1168,6 +1194,7 @@ begin
|
||||
AddPageBottomSpace(FPageWaterfall);
|
||||
AddPageBottomSpace(FPageCalib);
|
||||
AddPageBottomSpace(FPageCAT);
|
||||
AddPageBottomSpace(FPageDXCluster);
|
||||
AddPageBottomSpace(FPageOC);
|
||||
|
||||
FBtnClose := TFlatButton.Create(Self);
|
||||
@@ -1390,6 +1417,7 @@ begin
|
||||
ResizePage(FPagePA, CardWidth);
|
||||
ResizePage(FPageCalib, CardWidth);
|
||||
ResizePage(FPageAdvanced, CardWidth);
|
||||
ResizePage(FPageDXCluster, CardWidth);
|
||||
ResizePage(FPageCAT, CardWidth);
|
||||
ResizePage(FPageSlices, CardWidth);
|
||||
ResizePage(FPageAlex, CardWidth);
|
||||
@@ -1408,6 +1436,7 @@ begin
|
||||
else if Sender = FNavPA then SelectPage(FPagePA, FNavPA)
|
||||
else if Sender = FNavCalib then SelectPage(FPageCalib, FNavCalib)
|
||||
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 = FNavSlices then SelectPage(FPageSlices, FNavSlices)
|
||||
else if Sender = FNavAlex then SelectPage(FPageAlex, FNavAlex)
|
||||
@@ -1429,6 +1458,7 @@ begin
|
||||
FPagePA.Visible := APage = FPagePA;
|
||||
FPageCalib.Visible := APage = FPageCalib;
|
||||
FPageAdvanced.Visible := APage = FPageAdvanced;
|
||||
FPageDXCluster.Visible := APage = FPageDXCluster;
|
||||
FPageCAT.Visible := APage = FPageCAT;
|
||||
FPageSlices.Visible := APage = FPageSlices;
|
||||
FPageAlex.Visible := APage = FPageAlex;
|
||||
@@ -1444,6 +1474,7 @@ begin
|
||||
FNavPA.Active := ANav = FNavPA;
|
||||
FNavCalib.Active := ANav = FNavCalib;
|
||||
FNavAdvanced.Active := ANav = FNavAdvanced;
|
||||
FNavDXCluster.Active := ANav = FNavDXCluster;
|
||||
FNavCAT.Active := ANav = FNavCAT;
|
||||
FNavSlices.Active := ANav = FNavSlices;
|
||||
FNavAlex.Active := ANav = FNavAlex;
|
||||
@@ -3412,6 +3443,227 @@ begin
|
||||
Lbl.Font.Size := 8;
|
||||
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);
|
||||
begin
|
||||
if FLoading then Exit;
|
||||
@@ -3540,6 +3792,11 @@ procedure TSettingsForm.ApplyTheme(const T: TAppTheme);
|
||||
TFlatFloatSpinEdit(Ctrl).SetAppTheme(T)
|
||||
else if Ctrl is TFlatRadioButton then
|
||||
TFlatRadioButton(Ctrl).SetAppTheme(T)
|
||||
// TFlatMemo — обёртка над TMemo со своим scrollbar: тему знает сама,
|
||||
// рекурсивный обход внутрь ей только навредил бы (внутренний TMemo не
|
||||
// подходит ни под одну ветку и остался бы серым).
|
||||
else if Ctrl is TFlatMemo then
|
||||
TFlatMemo(Ctrl).SetAppTheme(T)
|
||||
else if Ctrl is TListBox then
|
||||
begin
|
||||
TListBox(Ctrl).Color := T.BG;
|
||||
|
||||
+109
-60
@@ -20,7 +20,7 @@ interface
|
||||
uses
|
||||
Classes, SysUtils, Graphics, GraphType, ExtCtrls, Controls, Math,
|
||||
AppTheme,
|
||||
AlertOverlay, SampleRateOverlay, VfoOverlay, BandPlanOverlay,
|
||||
AlertOverlay, SampleRateOverlay, VfoOverlay, BandPlanOverlay, DXSpotOverlay,
|
||||
WaterfallView, SMeterView, RulerView, RadioModes,
|
||||
Settings; // FilterEdgesFromBW — единственная таблица знака боковой
|
||||
|
||||
@@ -106,6 +106,7 @@ type
|
||||
FVfoOverlay: TVfoOverlay;
|
||||
FSliceOverlays: TFPList; // доп. слайс-флаги (B+); не владеет (владелец MainForm)
|
||||
FBandPlanOverlay: TBandPlanOverlay;
|
||||
FDXSpotOverlay: TDXSpotOverlay; // споты DX-кластера (полоса подписей + штрихи)
|
||||
// ── Marker ────────────────────────────────────────────────────────────────
|
||||
FMarkerActive: Boolean;
|
||||
FMarkerX: Integer;
|
||||
@@ -159,7 +160,7 @@ type
|
||||
procedure RawHLine(Y, X1, X2: Integer; Color: TColor;
|
||||
OnPx: Integer = 0; OffPx: Integer = 0);
|
||||
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);
|
||||
function GetTextMask(const S: string; FontSize: Integer;
|
||||
Bold: Boolean): TBitmap;
|
||||
@@ -171,7 +172,12 @@ type
|
||||
procedure DrawSliceFilterBands(W, H: Integer);
|
||||
procedure DrawSliceFilterLinesRaw(W, H: Integer);
|
||||
procedure DrawBandLetterRaw(X1, X2: Integer; L: Char; Clr: TColor);
|
||||
// Y, с которого начинать вертикаль несущей, чтобы не перечеркнуть букву.
|
||||
function CarrierTopY(X1, X2, CarrierX: Integer; L: Char): Integer;
|
||||
procedure DrawBeaconMarkersRaw(W, H: Integer);
|
||||
// Штрихи DX-спотов ниже полосы подписей. Раскладка уже посчитана оверлеем
|
||||
// при пересборке кэша — здесь только N вертикальных линий.
|
||||
procedure DrawDXSpotTicksRaw(W, H: Integer);
|
||||
// 2TON/IMD: замер пиков по TX-буферу (общий для CPU/GL рендеров).
|
||||
// Возвращает число валидных маркеров в M (0 если измерять нечего):
|
||||
// [0..1] тона, [2..3] IMD3 (2f1−f2, 2f2−f1). Обновляет FIMDSummary.
|
||||
@@ -179,7 +185,6 @@ type
|
||||
procedure DrawIMDMarkersRaw(W, H: Integer; DBmax, InvRange: Double);
|
||||
procedure RawCircle(CX, CY, R: Integer; Color: TColor);
|
||||
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 DrawADCOverloadRaw(W, H: Integer);
|
||||
procedure CalcFilterBandX(VfoFreq: Double; W: Integer;
|
||||
@@ -318,6 +323,7 @@ type
|
||||
function SliceOverlayCount: Integer;
|
||||
function SliceOverlayAt(Index: Integer): TVfoOverlay;
|
||||
property BandPlanOverlay: TBandPlanOverlay read FBandPlanOverlay write FBandPlanOverlay;
|
||||
property DXSpotOverlay: TDXSpotOverlay read FDXSpotOverlay write FDXSpotOverlay;
|
||||
|
||||
// ── Данные от DSP ─────────────────────────────────────────────────────────
|
||||
procedure SetSpectrumData(const Pixels: array of Single; Count: Integer); virtual;
|
||||
@@ -361,6 +367,9 @@ const
|
||||
// Полоса фильтра передающего тракта (главный VFO при TX / передающий слайс).
|
||||
// TColor = $00BBGGRR, младший байт = R (как в BlendBand/RawPack).
|
||||
CLR_TX_BAND = TColor($003030E0);
|
||||
// Кегль буквы слайса на полосе фильтра. Один на рисование буквы и на расчёт
|
||||
// зазора под неё (CarrierTopY) — иначе зазор разъедется с глифом.
|
||||
BAND_LETTER_FONT = 8;
|
||||
|
||||
// Упаковка TColor в пиксель кадра (непрозрачный). Раскладка байтов как в
|
||||
// BlendBand: non-Darwin = BGRA, Darwin = ARGB.
|
||||
@@ -669,6 +678,26 @@ begin
|
||||
FBeaconDecHalf := HalfHz;
|
||||
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);
|
||||
// Две вертикали: опорная частота маяка (зелёная пунктир) и отслеживаемый
|
||||
// центроид (оранжевая сплошная). Расхождение видно глазом → понятно, сел ли
|
||||
@@ -930,14 +959,16 @@ begin
|
||||
end;
|
||||
|
||||
// Треугольник остриём вниз (курсор 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
|
||||
Y, Half: Integer;
|
||||
begin
|
||||
for Y := 0 to Hgt do
|
||||
begin
|
||||
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;
|
||||
|
||||
@@ -1136,41 +1167,6 @@ end;
|
||||
// Canvas.FillRect для полосы фильтра в DrawSpectrum: полоса рисуется в
|
||||
// raw-фазе (между memcpy сетки и градиентом), а FillRect дёргал бы Canvas
|
||||
// и форсил лишнюю синхронизацию битмапа на 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+) на спектре — полупрозрачная полоса
|
||||
// пропускания + края + центральная несущая. Янтарный цвет, как в GL-вьюхе
|
||||
// (SpectrumViewOpengl.DrawSliceFilterMarkers). Полосы (raw ScanLine-блендинг)
|
||||
@@ -1192,7 +1188,7 @@ begin
|
||||
CalcSliceBandX(O, W, SX1, SX2, SVfoX);
|
||||
if SX2 > SX1 then
|
||||
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;
|
||||
|
||||
@@ -1211,7 +1207,9 @@ begin
|
||||
CalcSliceBandX(O, W, SX1, SX2, SVfoX);
|
||||
RawVLine(SX1, 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));
|
||||
@@ -1226,7 +1224,30 @@ var
|
||||
begin
|
||||
if (L < 'A') or (L > 'Z') then Exit;
|
||||
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;
|
||||
|
||||
procedure TSpectrumView.CopyGridToSpectrum(W, H: Integer);
|
||||
@@ -1544,7 +1565,8 @@ var
|
||||
DBmin, DBmax, dB: Double;
|
||||
VfoX, X1, X2: Integer;
|
||||
TXVfoX, TXX1, TXX2: Integer;
|
||||
AGCy, AGCHangY: Integer;
|
||||
AGCy, AGCHangY, CarrY: Integer;
|
||||
MainLetter: Char;
|
||||
SrcF, Frac, dBv: Double;
|
||||
S0, S1: Integer;
|
||||
InvRange: Double;
|
||||
@@ -1705,6 +1727,10 @@ begin
|
||||
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 — весь кадр одним локом ═════════════════════════════════
|
||||
{$IFDEF DARWIN}
|
||||
// Cocoa-вариант CopyGridToSpectrum идёт через Canvas.Draw — до RawBegin.
|
||||
@@ -1727,7 +1753,14 @@ begin
|
||||
end else
|
||||
if X2 > X1 then BlendBand(X1, X2, H, $E0, $30, $30, 100);
|
||||
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-вьюхе.
|
||||
DrawSliceFilterBands(W, H);
|
||||
@@ -1738,12 +1771,34 @@ begin
|
||||
DrawSpectrumGradient(FSpPts, W, H);
|
||||
|
||||
|
||||
// ★ Кромки, несущая и буква ГЛАВНОГО фильтра — здесь, ДО кривой спектра,
|
||||
// ровно как у слайсов ниже и как в GL-вьюхе (там весь блок фильтров идёт
|
||||
// перед DrawSpectrumCurve). Раньше этот кусок стоял ПОСЛЕ кривой, и на
|
||||
// CPU-пути главный фильтр единственный лез поверх сигнала — с виду толще и
|
||||
// ярче слайсовых, хотя цвета и толщина те же.
|
||||
RawVLine(X1, 0, H - 1, FTheme.SpecFilterEdge);
|
||||
RawVLine(X2, 0, H - 1, FTheme.SpecFilterEdge);
|
||||
// Буква главного флага (A) на его полосе — только когда есть слайсы,
|
||||
// иначе одиночный приём не засоряем.
|
||||
// иначе одиночный приём не засоряем. Букву запоминаем: под неё
|
||||
// подстраиваются вертикаль несущей и её треугольник.
|
||||
MainLetter := #0;
|
||||
if (FSliceOverlays <> nil) and (FSliceOverlays.Count > 0) and
|
||||
(FActiveVfo = 0) and Assigned(FVfoOverlay) then
|
||||
DrawBandLetterRaw(X1, X2, FVfoOverlay.SliceLetter,
|
||||
SliceColor(FVfoOverlay.SliceLetter));
|
||||
MainLetter := 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);
|
||||
@@ -1779,21 +1834,12 @@ begin
|
||||
// Линия спектра
|
||||
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 FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersRaw(W, H);
|
||||
// Штрихи DX-спотов — под маркерами наведения, но над кривой.
|
||||
DrawDXSpotTicksRaw(W, H);
|
||||
// 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при
|
||||
// 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре)
|
||||
if FIMDActive then DrawIMDMarkersRaw(W, H, DBmax, InvRange);
|
||||
@@ -1809,6 +1855,9 @@ begin
|
||||
// Бэндплан QO-100 — полоска внизу спектра, под панелями оверлеев.
|
||||
if Assigned(FBandPlanOverlay) then
|
||||
FBandPlanOverlay.DrawOverlay(FSpectrumBitmap, W, H);
|
||||
// Полоса подписей DX-спотов — вверху спектра, под флагами VFO.
|
||||
if Assigned(FDXSpotOverlay) then
|
||||
FDXSpotOverlay.DrawOverlay(FSpectrumBitmap, W, H);
|
||||
if Assigned(FSampleRateOverlay) then
|
||||
FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H);
|
||||
if Assigned(FVfoOverlay) then
|
||||
|
||||
+111
-11
@@ -18,6 +18,7 @@ uses
|
||||
Classes, SysUtils, Graphics, Controls, Math, Types,
|
||||
OpenGLContextEx, GL,
|
||||
AppTheme, AlertOverlay, SpectrumView, VfoOverlay, BandPlanOverlay,
|
||||
DXSpotOverlay,
|
||||
WaterfallView, WaterfallViewOpengl;
|
||||
|
||||
type
|
||||
@@ -36,6 +37,7 @@ type
|
||||
FSampleOverlayDirty: Boolean;
|
||||
FVfoOverlayDirty: Boolean;
|
||||
FBandOverlayDirty: Boolean;
|
||||
FDXOverlayDirty: Boolean;
|
||||
FGridLabelTex: TGLTextureCache;
|
||||
FAGCLabelTex: TGLTextureCache;
|
||||
FAGCHangLabelTex: TGLTextureCache;
|
||||
@@ -44,6 +46,7 @@ type
|
||||
FSampleOverlayTex: TGLTextureCache;
|
||||
FVfoOverlayTex: TGLTextureCache;
|
||||
FBandOverlayTex: TGLTextureCache;
|
||||
FDXOverlayTex: TGLTextureCache;
|
||||
// Текстуры флагов слайсов (B+), привязка по указателю оверлея.
|
||||
FSliceTex: array of record
|
||||
Overlay: TVfoOverlay;
|
||||
@@ -72,6 +75,8 @@ type
|
||||
FLastBandW: Integer;
|
||||
FLastBandCenter: Double;
|
||||
FLastBandSpan: Double;
|
||||
FLastDXW: Integer;
|
||||
FLastDXRender: Int64;
|
||||
FGLSpectrumW: Integer;
|
||||
FGLSpectrumH: Integer;
|
||||
FSpY: array of Integer;
|
||||
@@ -93,6 +98,7 @@ type
|
||||
procedure DrawSpectrumCurve(W, H: Integer; DBmax, InvRange: Double);
|
||||
procedure DrawMarker(W, H: Integer);
|
||||
procedure DrawBeaconMarkersGL(W, H: Integer);
|
||||
procedure DrawDXSpotTicksGL(W, H: Integer);
|
||||
procedure DrawCircleGL(CX, CY: Integer; R: Single; C: TColor);
|
||||
procedure DrawIMDMarkersGL(W, H: Integer; DBmax, InvRange: Double);
|
||||
procedure DrawADCOverlay(W, H: Integer);
|
||||
@@ -100,6 +106,9 @@ type
|
||||
procedure DrawSliceOverlays(W, H: Integer);
|
||||
procedure DrawSliceFilterMarkers(W, H: Integer);
|
||||
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 FormatFreqGL(Hz: Double): string;
|
||||
function ActiveVfoFrequency: Double;
|
||||
@@ -152,8 +161,10 @@ begin
|
||||
FSampleOverlayDirty := True;
|
||||
FVfoOverlayDirty := True;
|
||||
FBandOverlayDirty := True;
|
||||
FDXOverlayDirty := True;
|
||||
FLastBandW := -MaxInt;
|
||||
FLastBandCenter := -1; FLastBandSpan := -1;
|
||||
FLastDXW := -MaxInt; FLastDXRender := -1;
|
||||
FLastAGCY := -MaxInt;
|
||||
FLastAGCHangY := -MaxInt;
|
||||
FLastMarkerX := -MaxInt;
|
||||
@@ -173,6 +184,7 @@ begin
|
||||
DeleteTexture(FSampleOverlayTex);
|
||||
DeleteTexture(FVfoOverlayTex);
|
||||
DeleteTexture(FBandOverlayTex);
|
||||
DeleteTexture(FDXOverlayTex);
|
||||
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(FIMDLabelTex) do DeleteTexture(FIMDLabelTex[i]);
|
||||
@@ -454,6 +466,7 @@ begin
|
||||
Zap(FADCOverlayTex);
|
||||
Zap(FSampleOverlayTex);
|
||||
Zap(FVfoOverlayTex);
|
||||
Zap(FDXOverlayTex);
|
||||
Zap(FBandOverlayTex);
|
||||
for i := 0 to High(FSliceTex) do Zap(FSliceTex[i].Tex);
|
||||
for i := 0 to High(FLetterTex) do Zap(FLetterTex[i]);
|
||||
@@ -469,6 +482,7 @@ begin
|
||||
FSampleOverlayDirty := True;
|
||||
FVfoOverlayDirty := True;
|
||||
FBandOverlayDirty := True;
|
||||
FDXOverlayDirty := True;
|
||||
FGLSpectrumW := 0;
|
||||
FGLSpectrumH := 0;
|
||||
// Водопад — свой контекст/текстуры (история + маркер). При reparent (pop-out)
|
||||
@@ -581,7 +595,8 @@ end;
|
||||
|
||||
procedure TSpectrumViewOpenGL.DrawFilterAndCursors(W, H: Integer);
|
||||
var
|
||||
X1, X2, VfoX, TXX1, TXX2, TXVfoX: Integer;
|
||||
X1, X2, VfoX, TXX1, TXX2, TXVfoX, CarrY: Integer;
|
||||
MainLetter: Char;
|
||||
begin
|
||||
TXX1 := 0;
|
||||
TXX2 := 0;
|
||||
@@ -607,24 +622,35 @@ begin
|
||||
else if X2 > X1 then DrawRect(X1, 0, X2, H, CLR_TX_BAND, 100 / 255);
|
||||
end
|
||||
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(X2, 0, X2, H, FTheme.SpecFilterEdge, 1, False);
|
||||
DrawLine(VfoX, 0, VfoX, H - 12, FTheme.SpecVfoCursor, 2, False);
|
||||
DrawRect(VfoX - 5, 0, VfoX + 5, 8, FTheme.SpecVfoCursor, 1);
|
||||
CarrY := CarrierTopYGL(X1, X2, VfoX, MainLetter);
|
||||
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
|
||||
begin
|
||||
DrawLine(TXX1, 0, TXX1, H, TColor($002030E0), 1, False);
|
||||
DrawLine(TXX2, 0, TXX2, H, TColor($002030E0), 1, False);
|
||||
DrawLine(TXVfoX, 0, TXVfoX, H - 12, TColor($002030E0), 2, False);
|
||||
DrawRect(TXVfoX - 5, 0, TXVfoX + 5, 8, TColor($002030E0), 1);
|
||||
// Буква нарисована на RX-полосе (X1..X2) — с ней и сверяемся.
|
||||
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;
|
||||
|
||||
// Буква главного флага (A) на его полосе — только когда есть слайсы.
|
||||
if (SliceOverlayCount > 0) and (FActiveVfo = 0) and Assigned(FVfoOverlay) then
|
||||
DrawBandLetterGL(X1, X2, FVfoOverlay.SliceLetter);
|
||||
if MainLetter <> #0 then DrawBandLetterGL(X1, X2, MainLetter);
|
||||
|
||||
DrawSliceFilterMarkers(W, H);
|
||||
end;
|
||||
@@ -646,10 +672,13 @@ begin
|
||||
else Clr := SliceColor(O.SliceLetter);
|
||||
CalcSliceBandX(O, W, SX1, SX2, SVfoX);
|
||||
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(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);
|
||||
end;
|
||||
end;
|
||||
@@ -668,6 +697,29 @@ begin
|
||||
DrawTexture(FLetterTex[idx], cx - FLetterTex[idx].W div 2, 0);
|
||||
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,
|
||||
InvRange: Double);
|
||||
var
|
||||
@@ -820,6 +872,28 @@ begin
|
||||
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);
|
||||
// Залитый кружок (triangle fan, 20 сегментов) — маркер пика 2TON/IMD.
|
||||
var
|
||||
@@ -947,6 +1021,31 @@ begin
|
||||
DrawTexture(FBandOverlayTex, 0, H - FBandOverlayTex.H);
|
||||
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
|
||||
begin
|
||||
OW := Min(FSampleRateOverlay.Width, W - FSampleRateOverlay.Left);
|
||||
@@ -1098,6 +1197,7 @@ begin
|
||||
DrawSpectrumCurve(W, H, DBmax, InvRange);
|
||||
DrawMarker(W, H);
|
||||
if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersGL(W, H);
|
||||
DrawDXSpotTicksGL(W, H);
|
||||
// 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при
|
||||
// 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре)
|
||||
if FIMDActive then DrawIMDMarkersGL(W, H, DBmax, InvRange);
|
||||
|
||||
+16
-1
@@ -38,7 +38,11 @@ const
|
||||
// бейджа-карточки во флаге и для буквы на полосе фильтра в спектре.
|
||||
SLICE_COLORS: array[0..7] of TColor = (
|
||||
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($00E070F0), // D — розовый/маджента
|
||||
TColor($0030E0F0), // E — жёлтый
|
||||
@@ -46,6 +50,11 @@ const
|
||||
TColor($00E0C060), // G — бирюзовый
|
||||
TColor($00C0C0C0)); // H — серый
|
||||
|
||||
// Прозрачность заливки полосы фильтра на спектре — одна на главный VFO и на
|
||||
// слайсы, чтобы полосы читались одинаково (и в CPU-, и в GL-вьюхе).
|
||||
SPEC_BAND_ALPHA = 60; // приём
|
||||
SPEC_BAND_ALPHA_TX = 100; // передающая полоса — плотнее
|
||||
|
||||
// Цвет слайса по его букве (A..). За пределами таблицы — по кругу.
|
||||
function SliceColor(L: Char): TColor;
|
||||
|
||||
@@ -283,6 +292,12 @@ type
|
||||
property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate;
|
||||
end;
|
||||
|
||||
{ BlendBitmapKey — общий keyed-композит кэш-битмапа оверлея в кадр (дворд-
|
||||
блендинг, магента = прозрачность). Живёт здесь исторически; вынесен в
|
||||
interface, чтобы другие оверлеи той же модели (DXSpotOverlay) не заводили
|
||||
вторую копию этого же цикла. }
|
||||
procedure BlendBitmapKey(Target, Source: TBitmap; DstX, DstY: Integer; Alpha: Byte);
|
||||
|
||||
implementation
|
||||
|
||||
function SliceColor(L: Char): TColor;
|
||||
|
||||
@@ -59,6 +59,11 @@ procedure SockSetNonBlock(S: TSocket; NB: Boolean);
|
||||
если буфер клиента переполнен, держа при этом FClientLock и блокируя Stop(). }
|
||||
procedure SockSetSndTimeout(S: TSocket; Ms: Integer);
|
||||
|
||||
{ SockSetRcvTimeout — ограничивает время блокирующего SockRecv. Нужен клиентам,
|
||||
которым между пакетами надо просыпаться самим (проверить Terminated, отдать
|
||||
накопившиеся команды): recv возвращает -1 по таймауту, соединение живо. }
|
||||
procedure SockSetRcvTimeout(S: TSocket; Ms: Integer);
|
||||
|
||||
{ ── SHA-1 ─────────────────────────────────────────────────────────────────── }
|
||||
|
||||
type
|
||||
@@ -123,6 +128,13 @@ begin
|
||||
setsockopt(S, SOL_SOCKET, SO_SNDTIMEO, @T, SizeOf(T));
|
||||
end;
|
||||
|
||||
procedure SockSetRcvTimeout(S: TSocket; Ms: Integer);
|
||||
var T: DWORD;
|
||||
begin
|
||||
T := Ms;
|
||||
setsockopt(S, SOL_SOCKET, SO_RCVTIMEO, @T, SizeOf(T));
|
||||
end;
|
||||
|
||||
{$ELSE}
|
||||
|
||||
function SockClose(S: TSocket): Integer;
|
||||
@@ -165,6 +177,14 @@ begin
|
||||
fpSetSockOpt(S, SOL_SOCKET, SO_SNDTIMEO, @TV, SizeOf(TV));
|
||||
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}
|
||||
|
||||
{ ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<UseVersionInfo Value="True"/>
|
||||
<AutoIncrementBuild Value="True"/>
|
||||
<MinorVersionNr Value="9"/>
|
||||
<BuildNr Value="294"/>
|
||||
<BuildNr Value="295"/>
|
||||
</VersionInfo>
|
||||
<MacroValues Count="127">
|
||||
<MacroValues Count="129">
|
||||
<Macro1 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro2 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro3 Name="LCLWidgetType" Value="qt6"/>
|
||||
@@ -147,6 +147,8 @@
|
||||
<Macro125 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro126 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro127 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro128 Name="LCLWidgetType" Value="qt6"/>
|
||||
<Macro129 Name="LCLWidgetType" Value="qt6"/>
|
||||
</MacroValues>
|
||||
<BuildModes>
|
||||
<Item Name="Debug" Default="True"/>
|
||||
|
||||
Reference in New Issue
Block a user