diff --git a/BeaconScopeForm.pas b/BeaconScopeForm.pas index a5e3bf1..27e2fcd 100644 --- a/BeaconScopeForm.pas +++ b/BeaconScopeForm.pas @@ -17,19 +17,26 @@ interface uses Classes, SysUtils, Math, StrUtils, Forms, Controls, Graphics, ExtCtrls, StdCtrls, - AppTheme, BeaconDecoder, BeaconFEC, RadioController; + AppTheme, BeaconDecoder, BeaconFEC, RadioController, DpiUtils; type TBeaconScopeForm = class(TForm) private FCtl: TRadioController; + FHeader: TPanel; + FTitle: TLabel; + FSubtitle: TLabel; FBox: TPaintBox; + FBulletin: TPanel; + FBulletinTitle: TLabel; + FBulletinHint: TLabel; FMemo: TMemo; // декодированный текст бюллетеня (AO-40 кадры) FTimer: TTimer; FTheme: TAppTheme; FLastFrames: Int64; // счётчик кадров на прошлом тике (детект нового) procedure DoTick(Sender: TObject); procedure DoPaint(Sender: TObject); + procedure FormResize(Sender: TObject); procedure PollFrame; function FrameToText(const Frame: TBeaconFrame): string; public @@ -40,26 +47,105 @@ type implementation constructor TBeaconScopeForm.CreateWith(AOwner: TComponent; ACtl: TRadioController); +var + WorkArea: TRect; + MaxW, MaxH: Integer; begin inherited CreateNew(AOwner); FCtl := ACtl; FTheme := DarkTheme; - Caption := 'QO-100 Beacon — Constellation + Bulletin'; + // Геометрия формы задаётся ниже уже в DPI-aware координатах. + Scaled := False; + Caption := 'QO-100 Beacon'; BorderStyle := bsSizeable; - Width := 600; - Height := 680; - Position := poScreenCenter; + if AOwner is TCustomForm then + begin + WorkArea := TCustomForm(AOwner).BoundsRect; + WorkArea := Screen.MonitorFromRect(WorkArea).WorkareaRect; + Position := poOwnerFormCenter; + end + else + begin + WorkArea := Screen.PrimaryMonitor.WorkareaRect; + Position := poScreenCenter; + end; + MaxW := WorkArea.Right - WorkArea.Left - DpiScale(48); + MaxH := WorkArea.Bottom - WorkArea.Top - DpiScale(48); + Width := Min(DpiScale(760), MaxW); + Height := Min(DpiScale(700), MaxH); + Constraints.MinWidth := Min(DpiScale(520), Width); + Constraints.MinHeight := Min(DpiScale(560), Height); + Color := FTheme.BG; - // Текст бюллетеня (декодированные AO-40 кадры) — снизу, моноширинный, ReadOnly. + FHeader := TPanel.Create(Self); + FHeader.Parent := Self; + FHeader.Align := alTop; + FHeader.Height := DpiScale(72); + FHeader.BevelOuter := bvNone; + FHeader.Color := FTheme.Panel; + + FTitle := TLabel.Create(Self); + FTitle.Parent := FHeader; + FTitle.Caption := 'QO-100 BEACON DECODER'; + FTitle.AutoSize := False; + FTitle.SetBounds(DpiScale(20), DpiScale(12), + DpiScale(520), DpiScale(26)); + FTitle.Font.Size := 15; + FTitle.Font.Style := [fsBold]; + FTitle.Font.Color := FTheme.Text; + + FSubtitle := TLabel.Create(Self); + FSubtitle.Parent := FHeader; + FSubtitle.Caption := + 'BPSK constellation, carrier recovery and AO-40 telemetry'; + FSubtitle.AutoSize := False; + FSubtitle.SetBounds(DpiScale(20), DpiScale(40), + DpiScale(620), DpiScale(20)); + FSubtitle.Font.Size := 9; + FSubtitle.Font.Color := FTheme.TextDim; + + FBulletin := TPanel.Create(Self); + FBulletin.Parent := Self; + FBulletin.Align := alBottom; + FBulletin.Height := DpiScale(236); + FBulletin.BevelOuter := bvNone; + FBulletin.Color := FTheme.Panel; + + FBulletinTitle := TLabel.Create(Self); + FBulletinTitle.Parent := FBulletin; + FBulletinTitle.Caption := 'DECODED BULLETIN'; + FBulletinTitle.AutoSize := False; + FBulletinTitle.SetBounds(DpiScale(16), DpiScale(9), + DpiScale(220), DpiScale(20)); + FBulletinTitle.Font.Size := 9; + FBulletinTitle.Font.Style := [fsBold]; + FBulletinTitle.Font.Color := FTheme.MeterOn; + + FBulletinHint := TLabel.Create(Self); + FBulletinHint.Parent := FBulletin; + FBulletinHint.Caption := 'latest AO-40 frames · read-only'; + FBulletinHint.AutoSize := False; + FBulletinHint.SetBounds(DpiScale(250), DpiScale(9), + DpiScale(300), DpiScale(20)); + FBulletinHint.Anchors := [akTop, akRight]; + FBulletinHint.Left := FBulletin.ClientWidth - DpiScale(316); + FBulletinHint.Alignment := taRightJustify; + FBulletinHint.Font.Size := 8; + FBulletinHint.Font.Color := FTheme.TextDim; + + // Бюллетень остаётся обычным read-only memo: текст можно выделить и скопировать. FMemo := TMemo.Create(Self); - FMemo.Parent := Self; - FMemo.Align := alBottom; - FMemo.Height := 230; + FMemo.Parent := FBulletin; + FMemo.Align := alClient; + FMemo.BorderSpacing.Left := DpiScale(12); + FMemo.BorderSpacing.Top := DpiScale(36); + FMemo.BorderSpacing.Right := DpiScale(12); + FMemo.BorderSpacing.Bottom := DpiScale(12); + FMemo.BorderStyle := bsNone; FMemo.ReadOnly := True; FMemo.ScrollBars := ssVertical; FMemo.WordWrap := False; - FMemo.Font.Name := 'Courier New'; FMemo.Font.Size := 9; FMemo.Color := FTheme.BG; FMemo.Font.Color := FTheme.Text; @@ -68,6 +154,8 @@ begin FBox.Parent := Self; FBox.Align := alClient; FBox.OnPaint := @DoPaint; + OnResize := @FormResize; + FormResize(nil); FTimer := TTimer.Create(Self); FTimer.Interval := 40; // ~25 Гц @@ -78,10 +166,42 @@ end; procedure TBeaconScopeForm.ApplyTheme(const T: TAppTheme); begin FTheme := T; + Color := T.BG; + if FHeader <> nil then FHeader.Color := T.Panel; + if FTitle <> nil then FTitle.Font.Color := T.Text; + if FSubtitle <> nil then FSubtitle.Font.Color := T.TextDim; + if FBulletin <> nil then FBulletin.Color := T.Panel; + if FBulletinTitle <> nil then FBulletinTitle.Font.Color := T.MeterOn; + if FBulletinHint <> nil then FBulletinHint.Font.Color := T.TextDim; if FMemo <> nil then begin - FMemo.Color := FTheme.BG; - FMemo.Font.Color := FTheme.Text; + FMemo.Color := T.BG; + FMemo.Font.Color := T.Text; + end; + if FBox <> nil then FBox.Invalidate; +end; + +procedure TBeaconScopeForm.FormResize(Sender: TObject); +var + BulletinH, MaxBulletinH: Integer; +begin + if (FHeader <> nil) and (FBulletin <> nil) then + begin + MaxBulletinH := ClientHeight - FHeader.Height - DpiScale(270); + BulletinH := Min(DpiScale(236), + Max(DpiScale(150), MaxBulletinH)); + if FBulletin.Height <> BulletinH then + FBulletin.Height := BulletinH; + end; + if FTitle <> nil then + FTitle.Width := Max(0, FHeader.ClientWidth - DpiScale(40)); + if FSubtitle <> nil then + FSubtitle.Width := Max(0, FHeader.ClientWidth - DpiScale(40)); + if FBulletinHint <> nil then + begin + FBulletinHint.Left := DpiScale(250); + FBulletinHint.Width := Max(0, + FBulletin.ClientWidth - DpiScale(266)); end; if FBox <> nil then FBox.Invalidate; end; @@ -134,46 +254,163 @@ end; procedure TBeaconScopeForm.DoPaint(Sender: TObject); var C: TCanvas; - W, H, cx, cy, r, i, px, py: Integer; + W, H, M, Gap, CardGap, GraphSize, cx, cy, r, i, px, py: Integer; + GraphR, MetricsR, PlotR: TRect; S: TBeaconScope; - ok: Boolean; - C1, C2: TColor; - st: string; + ok, Wide: Boolean; + CarrierText, SymbolText, SNRText, BaudText: string; + OffsetText, ResidText, FramesText, FECText: string; + TechText: string; + CarrierColor, SymbolColor: TColor; + + procedure SetUIFont(ASize: Integer; AStyle: TFontStyles = []); + begin + C.Font.Assign(Self.Font); + C.Font.Size := ASize; + C.Font.Style := AStyle; + end; + + procedure DrawCard(const R: TRect; const ATitle: string); + begin + C.Brush.Style := bsSolid; + C.Brush.Color := FTheme.Panel; + C.Pen.Style := psSolid; + C.Pen.Width := 1; + C.Pen.Color := FTheme.Border; + C.RoundRect(R.Left, R.Top, R.Right, R.Bottom, + DpiScale(8), DpiScale(8)); + SetUIFont(8, [fsBold]); + C.Font.Color := FTheme.TextDim; + C.Brush.Style := bsClear; + C.TextOut(R.Left + DpiScale(14), R.Top + DpiScale(10), ATitle); + end; + + procedure DrawMetricTile(const R: TRect; const ALabel, AValue: string; + AValueColor: TColor); + begin + C.Brush.Style := bsSolid; + C.Brush.Color := FTheme.BG; + C.Pen.Style := psSolid; + C.Pen.Color := FTheme.Border; + C.RoundRect(R.Left, R.Top, R.Right, R.Bottom, + DpiScale(5), DpiScale(5)); + + C.Brush.Style := bsClear; + SetUIFont(8); + C.Font.Color := FTheme.TextDim; + C.TextOut(R.Left + DpiScale(9), R.Top + DpiScale(6), ALabel); + SetUIFont(9, [fsBold]); + C.Font.Color := AValueColor; + C.TextOut(R.Left + DpiScale(9), R.Top + DpiScale(21), AValue); + end; + + procedure DrawMetrics; + const + LABELS: array[0..7] of string = + ('CARRIER', 'SYMBOL', 'SNR', 'SYMBOL RATE', + 'TUNE OFFSET', 'RESIDUAL', 'FRAMES', 'FEC'); + var + Values: array[0..7] of string; + Colors: array[0..7] of TColor; + Cols, Rows, Col, Row, TileW, TileH, X, Y, n: Integer; + R: TRect; + begin + Values[0] := CarrierText; Values[1] := SymbolText; + Values[2] := SNRText; Values[3] := BaudText; + Values[4] := OffsetText; Values[5] := ResidText; + Values[6] := FramesText; Values[7] := FECText; + for n := 0 to 7 do Colors[n] := FTheme.Text; + Colors[0] := CarrierColor; + Colors[1] := SymbolColor; + if ok and S.HasFrame then Colors[6] := FTheme.MeterOn; + if ok and S.HasFrame then Colors[7] := FTheme.MeterOn; + + if Wide then begin Cols := 2; Rows := 4; end + else begin Cols := 4; Rows := 2; end; + CardGap := DpiScale(8); + TileW := (MetricsR.Right - MetricsR.Left - DpiScale(28) + - (Cols - 1) * CardGap) div Cols; + TileH := (MetricsR.Bottom - MetricsR.Top - DpiScale(70) + - (Rows - 1) * CardGap) div Rows; + for n := 0 to 7 do + begin + Col := n mod Cols; + Row := n div Cols; + X := MetricsR.Left + DpiScale(14) + Col * (TileW + CardGap); + Y := MetricsR.Top + DpiScale(34) + Row * (TileH + CardGap); + R := Rect(X, Y, X + TileW, Y + TileH); + DrawMetricTile(R, LABELS[n], Values[n], Colors[n]); + end; + SetUIFont(7); + C.Font.Color := FTheme.TextDim; + C.Brush.Style := bsClear; + C.TextOut(MetricsR.Left + DpiScale(14), + MetricsR.Bottom - DpiScale(20), TechText); + end; + begin C := FBox.Canvas; W := FBox.Width; H := FBox.Height; - // фон C.Brush.Style := bsSolid; C.Brush.Color := FTheme.BG; C.FillRect(0, 0, W, H); + if (W <= 0) or (H <= 0) then Exit; - // область констелляции — квадрат сверху, текст снизу - r := (Min(W, H - 70) - 20) div 2; - if r < 20 then r := 20; - if r > 110 then r := 110; // кап: оставляем место метрикам над memo - cx := W div 2; - cy := 10 + r; + M := DpiScale(16); + Gap := DpiScale(14); + Wide := W >= DpiScale(600); + if Wide then + begin + GraphSize := Min(H - 2 * M, (W - 3 * M) div 2); + GraphR := Rect(M, M, M + GraphSize, H - M); + MetricsR := Rect(GraphR.Right + Gap, M, W - M, H - M); + end + else + begin + GraphSize := Max(DpiScale(74), H - DpiScale(154) - 3 * M); + GraphR := Rect(M, M, W - M, M + GraphSize); + MetricsR := Rect(M, GraphR.Bottom + Gap, W - M, H - M); + end; + DrawCard(GraphR, 'CONSTELLATION'); + DrawCard(MetricsR, 'DECODER STATUS'); - // сетка/оси + // Квадрат констелляции центрируется внутри своей карточки. + GraphSize := Min(GraphR.Right - GraphR.Left - DpiScale(28), + GraphR.Bottom - GraphR.Top - DpiScale(48)); + GraphSize := Max(DpiScale(40), GraphSize); + PlotR.Left := GraphR.Left + + (GraphR.Right - GraphR.Left - GraphSize) div 2; + PlotR.Top := GraphR.Top + DpiScale(34) + + (GraphR.Bottom - GraphR.Top - DpiScale(40) - GraphSize) div 2; + PlotR.Right := PlotR.Left + GraphSize; + PlotR.Bottom := PlotR.Top + GraphSize; + cx := (PlotR.Left + PlotR.Right) div 2; + cy := (PlotR.Top + PlotR.Bottom) div 2; + r := GraphSize div 2 - DpiScale(8); + + C.Brush.Style := bsSolid; + C.Brush.Color := FTheme.BG; + C.Pen.Style := psSolid; + C.Pen.Color := FTheme.Border; + C.Rectangle(PlotR.Left, PlotR.Top, PlotR.Right, PlotR.Bottom); C.Pen.Style := psSolid; C.Pen.Color := FTheme.SpecGrid; - C.Line(cx - r, cy, cx + r, cy); // ось I (горизонт) - C.Line(cx, cy - r, cx, cy + r); // ось Q (вертикаль) - C.Pen.Color := FTheme.Border; + C.Line(cx - r, cy, cx + r, cy); + C.Line(cx, cy - r, cx, cy + r); C.Brush.Style := bsClear; - C.Rectangle(cx - r, cy - r, cx + r, cy + r); + C.Ellipse(cx - r, cy - r, cx + r, cy + r); + C.Ellipse(cx - r div 2, cy - r div 2, cx + r div 2, cy + r div 2); - // целевые точки BPSK (±1, 0) — ориентир C.Pen.Color := FTheme.TextDim; - C.Ellipse(cx + r div 2 - 3, cy - 3, cx + r div 2 + 3, cy + 3); - C.Ellipse(cx - r div 2 - 3, cy - 3, cx - r div 2 + 3, cy + 3); + C.Ellipse(cx + r div 2 - DpiScale(3), cy - DpiScale(3), + cx + r div 2 + DpiScale(3), cy + DpiScale(3)); + C.Ellipse(cx - r div 2 - DpiScale(3), cy - DpiScale(3), + cx - r div 2 + DpiScale(3), cy + DpiScale(3)); ok := (FCtl <> nil) and FCtl.GetBeaconScope(S); - if ok and S.Enabled then begin - // точки констелляции (нормированы к ±1; масштаб r/2 → ±1 на половине радиуса) C.Pen.Style := psClear; C.Brush.Style := bsSolid; C.Brush.Color := FTheme.MeterOn; @@ -182,53 +419,53 @@ begin px := cx + Round(S.PtI[i] * (r / 2)); py := cy - Round(S.PtQ[i] * (r / 2)); if (px >= cx - r) and (px <= cx + r) and (py >= cy - r) and (py <= cy + r) then - C.FillRect(px - 1, py - 1, px + 2, py + 2); + C.FillRect(px - DpiScale(1), py - DpiScale(1), + px + DpiScale(2), py + DpiScale(2)); end; end; - // ----- метрики ----- - C.Brush.Style := bsClear; - C.Font.Name := 'Courier New'; - C.Font.Size := 9; - py := cy + r + 12; - if not ok then begin - C.Font.Color := FTheme.TextDim; - C.TextOut(14, py, 'decoder unavailable'); - Exit; + CarrierText := 'UNAVAILABLE'; + SymbolText := 'UNAVAILABLE'; + SNRText := '--'; BaudText := '--'; + OffsetText := '--'; ResidText := '--'; + FramesText := '--'; FECText := '--'; + TechText := 'Decoder data unavailable'; + CarrierColor := FTheme.TextDim; + SymbolColor := FTheme.TextDim; end; - if not S.Enabled then + if ok and (not S.Enabled) then begin - C.Font.Color := FTheme.TextDim; - C.TextOut(14, py, 'decoder OFF (enable via BEACON \ decode)'); - Exit; + CarrierText := 'OFF'; SymbolText := 'OFF'; + SNRText := '--'; BaudText := '--'; + OffsetText := '--'; ResidText := '--'; + FramesText := IntToStr(S.Frames); + FECText := '--'; + TechText := 'Enable BEACON and select the beacon on the spectrum'; + CarrierColor := FTheme.TextDim; + SymbolColor := FTheme.TextDim; end; - - if S.CarrierLock then C1 := FTheme.MeterOn else C1 := FTheme.TextDim; - if S.SymbolLock then C2 := FTheme.MeterOn else C2 := FTheme.TextDim; - - C.Font.Color := C1; - C.TextOut(14, py, 'CARRIER ' + IfThen(S.CarrierLock, 'LOCK', '----')); - C.Font.Color := C2; - C.TextOut(14, py + 18, 'SYMBOL ' + IfThen(S.SymbolLock, 'LOCK', '----')); - - C.Font.Color := FTheme.Text; - if S.SNRdB > -50 then st := Format('%.1f dB', [S.SNRdB]) else st := '--'; - C.TextOut(14, py + 36, 'SNR ' + st); - C.TextOut(14, py + 54, Format('BAUD %.1f (off %.0f Hz)', [S.SymRate, S.OffsetHz])); - C.Font.Color := FTheme.TextDim; - C.TextOut(14, py + 72, Format('CARRIER resid %.0f Hz prom %.0f', [S.ResidHz, S.CarProm])); - if FCtl <> nil then - C.TextOut(14, py + 90, 'INVERT ' + IfThen(FCtl.BeaconDecodeInvert, 'ON', 'OFF')); - C.TextOut(14, py + 108, Format('RATE Fs %.0f Fdec %.0f sps %.1f', - [S.FsHz, S.FdecHz, S.Sps])); - - // ----- FEC (AO-40) ----- - if S.HasFrame then C.Font.Color := FTheme.MeterOn else C.Font.Color := FTheme.TextDim; - C.TextOut(14, py + 132, Format('FRAMES %d (RS err %d)', [S.Frames, S.LastRSErr])); - C.Font.Color := FTheme.TextDim; - C.TextOut(14, py + 150, Format('MANCHESTER phase %d', [S.ManPhase])); + if ok and S.Enabled then + begin + CarrierText := IfThen(S.CarrierLock, 'LOCKED', 'SEARCH'); + SymbolText := IfThen(S.SymbolLock, 'LOCKED', 'SEARCH'); + if S.SNRdB > -50 then SNRText := Format('%.1f dB', [S.SNRdB]) + else SNRText := '--'; + BaudText := Format('%.1f Bd', [S.SymRate]); + OffsetText := Format('%.0f Hz', [S.OffsetHz]); + ResidText := Format('%.0f Hz', [S.ResidHz]); + FramesText := IntToStr(S.Frames); + FECText := Format('RS %d · P%d', [S.LastRSErr, S.ManPhase]); + TechText := Format('INV %s · PROM %.0f · Fs %.0f · Fd %.0f · SPS %.1f', + [IfThen(FCtl.BeaconDecodeInvert, 'ON', 'OFF'), S.CarProm, + S.FsHz, S.FdecHz, S.Sps]); + if S.CarrierLock then CarrierColor := FTheme.MeterOn + else CarrierColor := FTheme.TextDim; + if S.SymbolLock then SymbolColor := FTheme.MeterOn + else SymbolColor := FTheme.TextDim; + end; + DrawMetrics; end; end.