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 placed = []; private BandmapScale scale = new(Bands.Band20M.Low, Bands.Band20M.High, 1); public BandmapView() => Focusable = true; public IReadOnlyList Spots { get; set; } = []; /// The height of the callsigns, in points. public double TextSize { get; set; } = 12; private double LabelHeight => TextSize + LabelPadding; 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); /// 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? SpotPicked; /// Clicking where no station is asks for the radio, not a callsign. public event EventHandler? FrequencyPicked; /// The wheel was turned: forward zooms in on the frequency under the pointer. public event EventHandler? 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 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, }; }