Found by tracing the app with a fake skimmer feed, a fake rigctld and eight tool windows open (about 3,500 spots on the bandmap). - Drop expired spots from the one-second spot timer instead of from each window's refresh on the UI thread. - Bandmap keeps the lists On and All return until the spots change, and Near searches only the band the frequency is in. - The Available window replaces its rows at most every two seconds, the Score window redraws at most once a second, and the Telnet list takes new lines four times a second at most (RateLimited). - The bandmap leader line pen is made once. Idle CPU in that setup went from about 33-70% of one core to about 16-25%. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
331 lines
12 KiB
C#
331 lines
12 KiB
C#
using Nonemm.App.Theming;
|
|
using System.Globalization;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Input;
|
|
using Avalonia.Media;
|
|
using Nonemm.Core;
|
|
using Nonemm.Spotting;
|
|
|
|
namespace Nonemm.App.Windows;
|
|
|
|
/// Draws the band: a frequency scale down the left, the receiver and
|
|
/// transmitter as bars on it, and the stations as callsigns on the right with a
|
|
/// leader line back to where each one really is.
|
|
public sealed record BandmapZoom(bool In, Frequency At);
|
|
|
|
public sealed class BandmapView : Control
|
|
{
|
|
private const double ScaleX = 52;
|
|
private const double LabelX = 96;
|
|
private const double TickLength = 6;
|
|
private const double ScaleTextSize = 12;
|
|
|
|
/// Callsigns are drawn this much taller than their text, so the lines of
|
|
/// two neighbours do not touch.
|
|
private const double LabelPadding = 3;
|
|
private const double VfoWidth = 4;
|
|
|
|
/// How far the carrier tick reaches past each side of the bar.
|
|
private const double CarrierOverhang = 3;
|
|
|
|
/// How much further the carrier tick reaches to the right, so it can be
|
|
/// seen beside the callsign labels.
|
|
private const double CarrierExtraRight = 15;
|
|
|
|
/// The scale stops short of the top and bottom edges so the first and last
|
|
/// frequency numbers are not cut in half.
|
|
private const double Inset = 9;
|
|
|
|
private static readonly IBrush ReceiveBrush = new SolidColorBrush(Color.FromRgb(0x27, 0xAE, 0x60));
|
|
private static readonly IBrush TransmitBrush = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B));
|
|
private static readonly IBrush SecondBrush = new SolidColorBrush(Color.FromRgb(0xE6, 0x7E, 0x22));
|
|
|
|
private static readonly IBrush CwBrush = new SolidColorBrush(Color.FromRgb(0x29, 0x80, 0xB9), 0.10);
|
|
private static readonly IBrush PhoneBrush = new SolidColorBrush(Color.FromRgb(0x27, 0xAE, 0x60), 0.10);
|
|
private static readonly IBrush DigitalBrush = new SolidColorBrush(Color.FromRgb(0x8E, 0x44, 0xAD), 0.13);
|
|
|
|
/// Past the band edge. The slice can reach there when the radio does.
|
|
private static readonly IBrush OutsideBrush = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B), 0.13);
|
|
|
|
private readonly Typeface typeface = new(Fonts.Mono);
|
|
private readonly Typeface boldTypeface = new(Fonts.Mono, FontStyle.Normal, FontWeight.Bold);
|
|
private readonly Typeface italicTypeface = new(Fonts.Mono, FontStyle.Italic);
|
|
private IReadOnlyList<BandmapLabel> placed = [];
|
|
private BandmapScale scale = new(Bands.Band20M.Low, Bands.Band20M.High, 1);
|
|
|
|
public BandmapView() => Focusable = true;
|
|
|
|
public IReadOnlyList<Spot> Spots { get; set; } = [];
|
|
|
|
/// The height of the callsigns, in points.
|
|
public double TextSize { get; set; } = 12;
|
|
|
|
private double LabelHeight => TextSize + LabelPadding;
|
|
|
|
public IReadOnlyList<VfoMarker> Vfos { get; set; } = [];
|
|
|
|
public Frequency Low { get; set; } = Bands.Band20M.Low;
|
|
|
|
public Frequency High { get; set; } = Bands.Band20M.High;
|
|
|
|
/// The CW and phone parts of the band, painted behind the scale. Null for a
|
|
/// band with no boundary in the band plan, and then nothing is painted.
|
|
public BandSegments? Segments { get; set; }
|
|
|
|
/// How the callsign is coloured: the same scorer as the entry window.
|
|
public Func<Spot, IBrush> ColourOf { get; set; } = _ => Brushes.Gray;
|
|
|
|
/// The step between the frequency numbers down the scale.
|
|
public Frequency TickStep { get; set; } = Frequency.FromKilohertz(5);
|
|
|
|
/// Sets the step from the height of the window instead: as many ticks as
|
|
/// fit with room for three lines of text between them.
|
|
public bool DenseTicks { get; set; }
|
|
|
|
public event EventHandler<Spot>? SpotPicked;
|
|
|
|
/// Clicking where no station is asks for the radio, not a callsign.
|
|
public event EventHandler<Frequency>? FrequencyPicked;
|
|
|
|
/// The wheel was turned: forward zooms in on the frequency under the pointer.
|
|
public event EventHandler<BandmapZoom>? Zoomed;
|
|
|
|
public override void Render(DrawingContext context)
|
|
{
|
|
scale = new BandmapScale(Low, High, Math.Max(1, Bounds.Height - (2 * Inset)));
|
|
IBrush ink = Foreground();
|
|
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
|
|
DrawSegments(context);
|
|
DrawScale(context, ink);
|
|
DrawVfos(context);
|
|
PlaceLabels();
|
|
foreach (BandmapLabel label in placed)
|
|
{
|
|
DrawLabel(context, label, ink);
|
|
}
|
|
}
|
|
|
|
private IReadOnlyList<Spot> placedSpots = [];
|
|
private BandmapScale? placedScale;
|
|
private double placedHeight;
|
|
|
|
/// Lays the callsigns out again only when the spots, the scale or the text
|
|
/// size are not what the last layout used. Redraws for any other reason, a
|
|
/// moving receiver bar for one, reuse it.
|
|
private void PlaceLabels()
|
|
{
|
|
if (placedScale == scale
|
|
&& placedHeight == LabelHeight
|
|
&& (ReferenceEquals(placedSpots, Spots) || placedSpots.SequenceEqual(Spots)))
|
|
{
|
|
return;
|
|
}
|
|
placedSpots = Spots;
|
|
placedScale = scale;
|
|
placedHeight = LabelHeight;
|
|
placed = [.. BandmapLayout.Place(Spots, scale, LabelHeight)
|
|
.Select(l => l with { MarkY = l.MarkY + Inset, LabelY = l.LabelY + Inset })];
|
|
}
|
|
|
|
/// N1MM paints the CW, digital and phone parts of the band behind the
|
|
/// scale, the digital block over the other two.
|
|
/// Anything past a band edge is painted as out of band, which is the only
|
|
/// place the edges themselves show.
|
|
private void DrawSegments(DrawingContext context)
|
|
{
|
|
if (Segments is not { } segments)
|
|
{
|
|
return;
|
|
}
|
|
Fill(context, segments.CwLow, segments.CwHigh, CwBrush);
|
|
if (segments.HasPhone)
|
|
{
|
|
Fill(context, segments.PhoneLow, segments.PhoneHigh, PhoneBrush);
|
|
}
|
|
if (segments.HasDigital)
|
|
{
|
|
Fill(context, segments.DigitalLow, segments.DigitalHigh, DigitalBrush);
|
|
}
|
|
Fill(context, Low, segments.CwLow, OutsideBrush);
|
|
Fill(context, segments.PhoneHigh, High, OutsideBrush);
|
|
}
|
|
|
|
private void Fill(DrawingContext context, Frequency from, Frequency to, IBrush brush)
|
|
{
|
|
double top = Math.Max(0, scale.YFor(from)) + Inset;
|
|
double bottom = Math.Min(scale.Height, scale.YFor(to)) + Inset;
|
|
if (bottom > top)
|
|
{
|
|
context.FillRectangle(brush, new Rect(0, top, Bounds.Width, bottom - top));
|
|
}
|
|
}
|
|
|
|
private void DrawScale(DrawingContext context, IBrush ink)
|
|
{
|
|
Pen line = new(Opaque(ink, 0.7), 1);
|
|
context.DrawLine(line, new Point(ScaleX, Inset), new Point(ScaleX, Inset + scale.Height));
|
|
foreach (Frequency tick in BandmapLayout.Ticks(scale, DenseTicks ? BandmapLayout.StepFor(scale, 3 * LabelHeight) : TickStep))
|
|
{
|
|
double y = Math.Round(scale.YFor(tick) + Inset) + 0.5;
|
|
context.DrawLine(line, new Point(ScaleX - TickLength, y), new Point(ScaleX, y));
|
|
FormattedText text = Text(
|
|
tick.Kilohertz.ToString("0.#", CultureInfo.InvariantCulture),
|
|
Opaque(ink, 0.8),
|
|
ScaleTextSize);
|
|
context.DrawText(text, new Point(ScaleX - TickLength - 4 - text.Width, y - (text.Height / 2)));
|
|
}
|
|
}
|
|
|
|
private void DrawVfos(DrawingContext context)
|
|
{
|
|
foreach (VfoMarker vfo in Vfos)
|
|
{
|
|
double top = scale.YFor(vfo.Low) + Inset;
|
|
double bottom = scale.YFor(vfo.High) + Inset;
|
|
// a narrow mode would be less than a pixel tall
|
|
double height = Math.Max(3, bottom - top);
|
|
IBrush brush = BrushFor(vfo.Role);
|
|
context.FillRectangle(brush, new Rect(ScaleX - (VfoWidth / 2), top, VfoWidth, height));
|
|
// the carrier, which is not in the middle of a sideband
|
|
double carrier = Math.Round(scale.YFor(vfo.Carrier) + Inset);
|
|
context.FillRectangle(
|
|
brush,
|
|
new Rect(ScaleX - (VfoWidth / 2) - CarrierOverhang, carrier - 1, VfoWidth + (2 * CarrierOverhang) + CarrierExtraRight, 2));
|
|
}
|
|
}
|
|
|
|
private void DrawLabel(DrawingContext context, BandmapLabel label, IBrush ink)
|
|
{
|
|
IBrush colour = ColourOf(label.Spot);
|
|
context.DrawLine(
|
|
LeaderPen(ink),
|
|
new Point(ScaleX + 2, label.MarkY),
|
|
new Point(LabelX - 2, label.LabelY + (LabelHeight / 2)));
|
|
context.DrawText(
|
|
Text(
|
|
label.Spot.Call.Text,
|
|
colour,
|
|
TextSize,
|
|
label.Spot.IsLocal || label.Spot.IsCqFrequency,
|
|
label.Spot.Source == SpotSource.Log),
|
|
new Point(LabelX, label.LabelY + (LabelPadding / 2)));
|
|
}
|
|
|
|
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
|
|
{
|
|
base.OnPointerWheelChanged(e);
|
|
if (e.Delta.Y != 0)
|
|
{
|
|
Zoomed?.Invoke(this, new BandmapZoom(e.Delta.Y > 0, scale.At(e.GetPosition(this).Y - Inset)));
|
|
e.Handled = true;
|
|
}
|
|
}
|
|
|
|
private BandmapLabel? LabelAt(Point at) =>
|
|
at.X < LabelX
|
|
? null
|
|
: placed.FirstOrDefault(l => at.Y >= l.LabelY && at.Y <= l.LabelY + LabelHeight);
|
|
|
|
protected override void OnPointerMoved(PointerEventArgs e)
|
|
{
|
|
base.OnPointerMoved(e);
|
|
Spot? under = LabelAt(e.GetPosition(this))?.Spot;
|
|
if (Equals(under, tipFor))
|
|
{
|
|
return;
|
|
}
|
|
tipFor = under;
|
|
ToolTip.SetShowDelay(this, 0);
|
|
ToolTip.SetTip(this, under is null ? null : SpotDescription.Of(under));
|
|
}
|
|
|
|
/// The spot the tool tip describes, so moving the pointer along a callsign
|
|
/// does not build the text again.
|
|
private Spot? tipFor;
|
|
|
|
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
|
{
|
|
base.OnPointerPressed(e);
|
|
Point at = e.GetPosition(this);
|
|
if (LabelAt(at) is { } label)
|
|
{
|
|
SpotPicked?.Invoke(this, label.Spot);
|
|
return;
|
|
}
|
|
FrequencyPicked?.Invoke(this, scale.At(at.Y - Inset));
|
|
}
|
|
|
|
/// Shaping text is the dearest part of a redraw, and the same few hundred
|
|
/// callsigns and frequency numbers are drawn every time, so each is kept.
|
|
private readonly Dictionary<(string Text, IBrush Brush, double Size, bool Bold, bool Italic), FormattedText> texts = [];
|
|
|
|
private FormattedText Text(string text, IBrush brush, double size, bool bold = false, bool italic = false)
|
|
{
|
|
if (texts.TryGetValue((text, brush, size, bold, italic), out FormattedText? kept))
|
|
{
|
|
return kept;
|
|
}
|
|
if (texts.Count > 2000)
|
|
{
|
|
texts.Clear();
|
|
}
|
|
FormattedText made = new(
|
|
text,
|
|
CultureInfo.InvariantCulture,
|
|
FlowDirection.LeftToRight,
|
|
bold ? boldTypeface : italic ? italicTypeface : typeface,
|
|
size,
|
|
brush);
|
|
texts[(text, brush, size, bold, italic)] = made;
|
|
return made;
|
|
}
|
|
|
|
/// The form's text colour, which the theme sets in both light and dark.
|
|
private IBrush Foreground() =>
|
|
this.FindResource("FormForeground") as IBrush ?? Brushes.Gray;
|
|
|
|
private Pen? leaderPen;
|
|
private IBrush? leaderInk;
|
|
|
|
/// The line from the scale to a callsign. Every label has the same one.
|
|
private Pen LeaderPen(IBrush ink)
|
|
{
|
|
if (leaderPen is null || !ReferenceEquals(leaderInk, ink))
|
|
{
|
|
leaderPen = new Pen(Opaque(ink, 0.55), 1);
|
|
leaderInk = ink;
|
|
}
|
|
return leaderPen;
|
|
}
|
|
|
|
private readonly Dictionary<(IBrush Ink, double Opacity), IBrush> faded = [];
|
|
|
|
/// Kept, so the same brush comes back each time and the text drawn with it
|
|
/// is found in `texts`.
|
|
private IBrush Opaque(IBrush ink, double opacity)
|
|
{
|
|
if (ink is not ISolidColorBrush solid)
|
|
{
|
|
return ink;
|
|
}
|
|
if (!faded.TryGetValue((ink, opacity), out IBrush? brush))
|
|
{
|
|
if (faded.Count > 50)
|
|
{
|
|
faded.Clear();
|
|
}
|
|
brush = new SolidColorBrush(solid.Color, opacity);
|
|
faded[(ink, opacity)] = brush;
|
|
}
|
|
return brush;
|
|
}
|
|
|
|
private static IBrush BrushFor(VfoRole role) => role switch
|
|
{
|
|
VfoRole.Transmit => TransmitBrush,
|
|
VfoRole.Second => SecondBrush,
|
|
_ => ReceiveBrush,
|
|
};
|
|
}
|