The zoom list gains 60 kHz and a "mode" level that shows the CW, digital or phone part of the band the receiver is in, before "band". For views from 40 to 200 kHz wide, the scale picks the smallest round step that keeps three lines of text between ticks. The font size buttons are removed; Ctrl with the wheel or plus and minus still change the size. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
243 lines
9.6 KiB
C#
243 lines
9.6 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 = 30;
|
|
|
|
/// 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 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);
|
|
placed = [.. BandmapLayout.Place(Spots, scale, LabelHeight)
|
|
.Select(l => l with { MarkY = l.MarkY + Inset, LabelY = l.LabelY + Inset })];
|
|
foreach (BandmapLabel label in placed)
|
|
{
|
|
DrawLabel(context, label, ink);
|
|
}
|
|
}
|
|
|
|
/// 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(
|
|
new Pen(Opaque(ink, 0.55), 1),
|
|
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),
|
|
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);
|
|
ToolTip.SetShowDelay(this, 0);
|
|
ToolTip.SetTip(
|
|
this,
|
|
LabelAt(e.GetPosition(this)) is { } label ? SpotDescription.Of(label.Spot) : null);
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
private FormattedText Text(string text, IBrush brush, double size, bool bold = false) =>
|
|
new(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, bold ? boldTypeface : typeface, size, brush);
|
|
|
|
/// 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 static IBrush Opaque(IBrush ink, double opacity) =>
|
|
ink is ISolidColorBrush solid
|
|
? new SolidColorBrush(solid.Color, opacity)
|
|
: ink;
|
|
|
|
private static IBrush BrushFor(VfoRole role) => role switch
|
|
{
|
|
VfoRole.Transmit => TransmitBrush,
|
|
VfoRole.Second => SecondBrush,
|
|
_ => ReceiveBrush,
|
|
};
|
|
}
|