Files
Nonemm/src/Nonemm.App/Windows/BandmapView.cs
T
erikbrockoandClaude Opus 5 d084789ed2 Paint the band the way N1MM paints it
Behind the bandmap scale, the CW part of the band is one shade and the phone
part another, with a third for a digital block. That is what N1MM's graphical
bandmap does: ResizeBandPanels puts a CW, a Digi and an SSB panel behind the
scale, sized from its CWBands, DigiBands and SSBBands tables. Anything past a
band edge is shaded red, which is the only place the edges themselves show, and
it only appears now that the slice can reach past an edge.

The defaults are N1MM's: CW ends at 1840, 3600, 7125, 14150 and so on, and the
digital pair is empty until somebody fills it in, exactly as N1MM ships. They
are one worldwide set rather than one per ITU region, which is also N1MM's
answer — the reason recorded for leaving this out, that the segments differ by
region, was wrong about what N1MM holds. 60M and everything above 2M get no
entry, because N1MM's own numbers there contradict themselves: its 1.25M CW top
is below its phone start, and its 70CM CW top is above the band.

Config ▸ Sub bands edits them, one row per band in kilohertz, with a Defaults
button. Only the rows that differ are written to the settings file, so a plan
nobody has touched leaves nothing behind. BandPlan is an object rather than a
static table, so the stored changes layer over the defaults and the bandmap
picks up a save without a restart.

The numbers are a drawing aid, not a licence. Nothing stops a contact being
logged either side of a boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 23:01:56 +00:00

188 lines
7.3 KiB
C#

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<BandmapLabel> placed = [];
private BandmapScale scale = new(Bands.Band20M.Low, Bands.Band20M.High, 1);
public BandmapView() => Focusable = true;
public IReadOnlyList<Spot> Spots { get; set; } = [];
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);
public event EventHandler<Spot>? SpotPicked;
/// Clicking where no station is asks for the radio, not a callsign.
public event EventHandler<Frequency>? 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,
};
}