Files
Nonemm/src/Nonemm.App/Windows/BandmapView.cs
T
erikbrockoandClaude Sonnet 5.5 5f9d186150 Cut idle CPU with a busy cluster feed and many windows open
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>
2026-10-25 13:34:50 +00:00

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,
};
}