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 class BandmapView : Control { private const double ScaleX = 52; private const double LabelX = 96; private const double TickLength = 6; private const double LabelHeight = 15; private const double TextSize = 12; private const double VfoWidth = 4; /// 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(new FontFamily("monospace")); private IReadOnlyList placed = []; private BandmapScale scale = new(Bands.Band20M.Low, Bands.Band20M.High, 1); public BandmapView() => Focusable = true; public IReadOnlyList Spots { get; set; } = []; public IReadOnlyList 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 ColourOf { get; set; } = _ => Brushes.Gray; /// The step between the frequency numbers down the scale. public Frequency TickStep { get; set; } = Frequency.FromKilohertz(5); public event EventHandler? SpotPicked; /// Clicking where no station is asks for the radio, not a callsign. public event EventHandler? FrequencyPicked; 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.5), 1); context.DrawLine(line, new Point(ScaleX, Inset), new Point(ScaleX, Inset + scale.Height)); foreach (Frequency tick in BandmapLayout.Ticks(scale, 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.6)); 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); context.FillRectangle( BrushFor(vfo.Role), new Rect(ScaleX - (VfoWidth / 2), top, VfoWidth, height)); } } private void DrawLabel(DrawingContext context, BandmapLabel label, IBrush ink) { IBrush colour = ColourOf(label.Spot); context.DrawLine( new Pen(Opaque(ink, 0.35), 1), new Point(ScaleX + 2, label.MarkY), new Point(LabelX - 2, label.LabelY + (LabelHeight / 2))); context.DrawText(Text(label.Spot.Call.Text, colour), new Point(LabelX, label.LabelY)); } protected override void OnPointerPressed(PointerPressedEventArgs e) { base.OnPointerPressed(e); Point at = e.GetPosition(this); foreach (BandmapLabel label in placed) { if (at.X >= LabelX && at.Y >= label.LabelY && at.Y <= label.LabelY + LabelHeight) { SpotPicked?.Invoke(this, label.Spot); return; } } FrequencyPicked?.Invoke(this, scale.At(at.Y - Inset)); } private FormattedText Text(string text, IBrush brush) => new(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, TextSize, brush); private IBrush Foreground() => this.FindResource("SystemControlForegroundBaseHighBrush") 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, }; }