diff --git a/README.md b/README.md index e8d0d27..3eb168b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points. | Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations | | Radio | hamlib `rigctld`, reconnecting on its own | | Cluster | DX cluster over telnet, spots feeding the bandmap, Alt+P to spot a station | +| Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines | | Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message | | Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros | @@ -119,6 +120,28 @@ Correcting the country prefix by hand changes the score. The country file is a best guess for calls it has no rule for, so what the contact says now wins over what the file says. +### The bandmap + +A frequency scale down the left with the stations written out beside it. Each +callsign sits level with its frequency, and when two stations are too close to +write one above the other the lower one moves down and a leader line runs back +to where it really is. That is what N1MM does, and the stacking follows its +rule: place the label centred on its frequency, and push it below the one above +if it would cover it. + +The slice shown follows the receiver — 10, 20, 40 or 100 kHz, or the whole band. +Callsigns are coloured by the same scorer as the entry window, so a dupe reads +as a dupe here too. Clicking a callsign puts the radio there with the call +already in the entry window; clicking anywhere else just moves the radio. + +The green bar on the scale is the receiver, as wide as the mode it is in. +`VfoRole` has transmit and second-radio bars ready, but nothing feeds them yet: +the radio does not report a transmit VFO or a second radio, so those bars stay +out rather than showing a guess. + +Band-plan colouring of the scale is not there. The segments differ by ITU region +and the program has no band-plan table, so it would be guesswork. + ### The DX cluster **Config → Cluster** takes the node's address, a password for the few nodes that @@ -174,7 +197,7 @@ covered by plain unit tests. Working: logging a contest end to end, live dupe and multiplier checking, eight built-in contests plus user-defined ones, Cabrillo and ADIF export, ADIF import, -the log, check, bandmap, score and packet windows, editing and deleting logged +the log, check, graphical bandmap, score and packet windows, editing and deleting logged contacts, radio control, DX cluster spots, contacts shared between networked stations, and CW keying. diff --git a/src/Nonemm.App/Windows/BandmapView.cs b/src/Nonemm.App/Windows/BandmapView.cs new file mode 100644 index 0000000..a85d671 --- /dev/null +++ b/src/Nonemm.App/Windows/BandmapView.cs @@ -0,0 +1,142 @@ +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 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; + + /// 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)); + 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); + } + } + + 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, + }; +} diff --git a/src/Nonemm.App/Windows/BandmapWindow.axaml b/src/Nonemm.App/Windows/BandmapWindow.axaml index 7823677..e69e2a7 100644 --- a/src/Nonemm.App/Windows/BandmapWindow.axaml +++ b/src/Nonemm.App/Windows/BandmapWindow.axaml @@ -2,12 +2,14 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:Nonemm.App.Windows" x:Class="Nonemm.App.Windows.BandmapWindow" - Title="Bandmap" Width="320" Height="520"> + Title="Bandmap" Width="280" Height="620"> - + - + - + + diff --git a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs index d3f2075..089b63d 100644 --- a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs +++ b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs @@ -1,17 +1,26 @@ -using Avalonia.Controls; -using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Threading; using Nonemm.Contests; using Nonemm.Core; using Nonemm.Spotting; namespace Nonemm.App.Windows; -/// The band as a list of stations in frequency order: cluster spots and every -/// station this log has worked. Clicking one puts the radio on it with the -/// callsign already in the entry window. +/// The band drawn as N1MM draws it: a frequency scale with the receiver on it +/// and the stations written out beside it. Clicking a callsign puts the radio +/// there with the call already in the entry window; clicking anywhere else just +/// moves the radio. public sealed partial class BandmapWindow : RefreshableWindow { + private static readonly (string Label, Frequency Span)[] Spans = + [ + ("10 kHz", Frequency.FromKilohertz(10)), + ("20 kHz", Frequency.FromKilohertz(20)), + ("40 kHz", Frequency.FromKilohertz(40)), + ("100 kHz", Frequency.FromKilohertz(100)), + ("whole band", Frequency.Zero), + ]; + private readonly AppSession session; private readonly Action tune; @@ -20,51 +29,74 @@ public sealed partial class BandmapWindow : RefreshableWindow this.session = session; this.tune = tune; InitializeComponent(); - Spots.SelectionChanged += (_, _) => OnPicked(); - ThisBandOnly.IsCheckedChanged += (_, _) => Refresh(); - session.Bandmap.Changed += (_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(Refresh); + SpanBox.ItemsSource = Spans.Select(s => s.Label).ToList(); + SpanBox.SelectedIndex = 2; + SpanBox.SelectionChanged += (_, _) => Refresh(); + View.ColourOf = Colour; + View.SpotPicked += (_, spot) => tune(spot.Frequency, spot.Call.Text); + View.FrequencyPicked += (_, frequency) => tune(frequency, ""); + session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh); Refresh(); } - public override void Refresh() { session.Bandmap.DropOlderThan(DateTime.UtcNow); Band? band = session.Logging is null ? null : Bands.ForFrequency(session.Logging.Frequency); BandText.Text = band?.Name ?? "no band"; + if (session.Logging is null || band is null) + { + View.Spots = []; + View.Vfos = []; + StatusText.Text = session.Logging is null ? "no contest is open" : "off band"; + View.InvalidateVisual(); + return; + } - IReadOnlyList spots = ThisBandOnly.IsChecked == true && band is not null - ? session.Bandmap.On(band) - : session.Bandmap.All(); - Spots.ItemsSource = spots.Select(BuildRow).ToList(); + (Frequency low, Frequency high) = Window(band, session.Logging.Frequency); + View.Low = low; + View.High = high; + View.TickStep = StepFor(high.Hertz - low.Hertz); + View.Spots = session.Bandmap.On(band); + View.Vfos = Vfos(); + StatusText.Text = $"{View.Spots.Count} stations · {low.Kilohertz:0.#} to {high.Kilohertz:0.#} kHz"; + View.InvalidateVisual(); } - private Control BuildRow(Spot spot) + /// Only the receiver is known. The radio does not report the transmit VFO + /// or a second radio yet, so those bars are not drawn; `VfoRole` has the + /// roles ready for when it does. + private IReadOnlyList Vfos() => + session.Logging is null + ? [] + : [VfoMarker.Centred( + session.Logging.Frequency, + Modes.Bandwidth(session.Logging.Mode), + VfoRole.Receive)]; + + /// The slice to show, centred on the receiver and kept inside the band. + private (Frequency Low, Frequency High) Window(Band band, Frequency centre) { - StackPanel row = new() { Orientation = Orientation.Horizontal, Tag = spot }; - row.Children.Add(new TextBlock + Frequency span = Spans[Math.Max(0, SpanBox.SelectedIndex)].Span; + if (span.Hertz == 0 || span.Hertz >= band.High.Hertz - band.Low.Hertz) { - Text = spot.Frequency.Kilohertz.ToString("0.0").PadLeft(8), - FontFamily = new FontFamily("monospace"), - Opacity = 0.75, - Margin = new Avalonia.Thickness(0, 0, 8, 0), - }); - row.Children.Add(new TextBlock - { - Text = spot.Call.Text, - FontFamily = new FontFamily("monospace"), - Foreground = Verdicts.Colour(VerdictFor(spot)), - }); - row.Children.Add(new TextBlock - { - Text = spot.Source == SpotSource.Log ? " worked" : $" {spot.Spotter}", - FontSize = 11, - Opacity = 0.6, - Margin = new Avalonia.Thickness(6, 2, 0, 0), - }); - return row; + return (band.Low, band.High); + } + long half = span.Hertz / 2; + long low = Math.Clamp(centre.Hertz - half, band.Low.Hertz, band.High.Hertz - span.Hertz); + return (Frequency.FromHertz(low), Frequency.FromHertz(low + span.Hertz)); } + private static Frequency StepFor(long span) => span switch + { + <= 20_000 => Frequency.FromKilohertz(2), + <= 60_000 => Frequency.FromKilohertz(5), + <= 200_000 => Frequency.FromKilohertz(20), + _ => Frequency.FromKilohertz(50), + }; + + private IBrush Colour(Spot spot) => Verdicts.Colour(VerdictFor(spot)); + private Verdict? VerdictFor(Spot spot) { if (session.Logging is null) @@ -81,12 +113,4 @@ public sealed partial class BandmapWindow : RefreshableWindow ContestName = session.Logging.Contest.Name, }); } - - private void OnPicked() - { - if (Spots.SelectedItem is Control { Tag: Spot spot }) - { - tune(spot.Frequency, spot.Call.Text); - } - } } diff --git a/src/Nonemm.Core/Modes.cs b/src/Nonemm.Core/Modes.cs index aec7ce0..813d31a 100644 --- a/src/Nonemm.Core/Modes.cs +++ b/src/Nonemm.Core/Modes.cs @@ -37,6 +37,14 @@ public static class Modes return ByName.TryGetValue(key, out Mode? mode) ? mode : null; } + /// Roughly what a receiver passes in this mode. The bandmap draws the + /// receiver as a bar this wide rather than a single line. + public static Frequency Bandwidth(Mode mode) => mode.Category switch + { + ModeCategory.Phone => Frequency.FromHertz(2_700), + _ => Frequency.FromHertz(500), + }; + /// The sideband convention: LSB below 10 MHz, USB above, and on 60 metres. public static Mode ForSideband(Frequency f) => f.Hertz < 10_000_000 && !Bands.Band60M.Contains(f) ? Lsb : Usb; diff --git a/src/Nonemm.Spotting/BandmapLabel.cs b/src/Nonemm.Spotting/BandmapLabel.cs new file mode 100644 index 0000000..77ebfae --- /dev/null +++ b/src/Nonemm.Spotting/BandmapLabel.cs @@ -0,0 +1,6 @@ +namespace Nonemm.Spotting; + +/// One callsign on the bandmap. `MarkY` is where the station really is on the +/// scale and `LabelY` is the top of the text, which is pushed down when the +/// station above is too close. The leader line joins the two. +public sealed record BandmapLabel(Spot Spot, double MarkY, double LabelY); diff --git a/src/Nonemm.Spotting/BandmapLayout.cs b/src/Nonemm.Spotting/BandmapLayout.cs new file mode 100644 index 0000000..fe64a32 --- /dev/null +++ b/src/Nonemm.Spotting/BandmapLayout.cs @@ -0,0 +1,52 @@ +using Nonemm.Core; + +namespace Nonemm.Spotting; + +/// Works out where each callsign goes. Two stations a hundred hertz apart would +/// have their text on top of each other, so a label that would cover the one +/// above is pushed below it and a leader line runs back to the real frequency. +/// This is what N1MM does. +public static class BandmapLayout +{ + /// N1MM leaves this much between two labels it had to separate. + private const double Gap = 0.5; + + public static IReadOnlyList Place( + IEnumerable spots, + BandmapScale scale, + double labelHeight) + { + List placed = []; + double? above = null; + foreach (Spot spot in spots + .Where(s => scale.Shows(s.Frequency)) + .OrderBy(s => s.Frequency.Hertz)) + { + double markY = scale.YFor(spot.Frequency); + double labelY = markY - (labelHeight / 2); + if (above is { } previous && previous + labelHeight > labelY) + { + labelY = previous + labelHeight + Gap; + } + placed.Add(new BandmapLabel(spot, markY, labelY)); + above = labelY; + } + return placed; + } + + /// The frequency ticks to draw, every `step` from the first round frequency + /// at or below the top of the scale. + public static IReadOnlyList Ticks(BandmapScale scale, Frequency step) + { + List ticks = []; + long first = scale.Low.Hertz - (scale.Low.Hertz % step.Hertz); + for (long at = first; at <= scale.High.Hertz; at += step.Hertz) + { + if (at >= scale.Low.Hertz) + { + ticks.Add(Frequency.FromHertz(at)); + } + } + return ticks; + } +} diff --git a/src/Nonemm.Spotting/BandmapScale.cs b/src/Nonemm.Spotting/BandmapScale.cs new file mode 100644 index 0000000..27b5a59 --- /dev/null +++ b/src/Nonemm.Spotting/BandmapScale.cs @@ -0,0 +1,19 @@ +using Nonemm.Core; + +namespace Nonemm.Spotting; + +/// The slice of a band the bandmap is showing, and how it maps to pixels. +/// Frequency runs down the window, so `Low` is at the top. +public sealed record BandmapScale(Frequency Low, Frequency High, double Height) +{ + public long Span => Math.Max(1, High.Hertz - Low.Hertz); + + public double YFor(Frequency frequency) => + (frequency.Hertz - Low.Hertz) * Height / Span; + + public Frequency At(double y) => + Frequency.FromHertz(Low.Hertz + (long)Math.Round(y * Span / Math.Max(1, Height))); + + public bool Shows(Frequency frequency) => + frequency >= Low && frequency <= High; +} diff --git a/src/Nonemm.Spotting/VfoMarker.cs b/src/Nonemm.Spotting/VfoMarker.cs new file mode 100644 index 0000000..0e6290f --- /dev/null +++ b/src/Nonemm.Spotting/VfoMarker.cs @@ -0,0 +1,15 @@ +using Nonemm.Core; + +namespace Nonemm.Spotting; + +/// Where a receiver or transmitter is sitting, drawn on the bandmap as a bar +/// covering what it hears or occupies rather than a single line. +public sealed record VfoMarker(Frequency Low, Frequency High, VfoRole Role) +{ + /// The bar for a receiver on `centre`, as wide as the mode it is in. + public static VfoMarker Centred(Frequency centre, Frequency width, VfoRole role) => + new( + Frequency.FromHertz(centre.Hertz - (width.Hertz / 2)), + Frequency.FromHertz(centre.Hertz + (width.Hertz / 2)), + role); +} diff --git a/src/Nonemm.Spotting/VfoRole.cs b/src/Nonemm.Spotting/VfoRole.cs new file mode 100644 index 0000000..e412420 --- /dev/null +++ b/src/Nonemm.Spotting/VfoRole.cs @@ -0,0 +1,9 @@ +namespace Nonemm.Spotting; + +/// Which of a station's receivers or transmitters a bandmap bar stands for. +public enum VfoRole +{ + Receive, + Transmit, + Second, +} diff --git a/tests/Nonemm.Spotting.Tests/BandmapLayoutTests.cs b/tests/Nonemm.Spotting.Tests/BandmapLayoutTests.cs new file mode 100644 index 0000000..0e31174 --- /dev/null +++ b/tests/Nonemm.Spotting.Tests/BandmapLayoutTests.cs @@ -0,0 +1,95 @@ +using Nonemm.Core; + +namespace Nonemm.Spotting.Tests; + +public class BandmapLayoutTests +{ + private const double LabelHeight = 10; + + private static readonly DateTime Now = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc); + + /// 14000 to 14100 kHz over 100 pixels, so one pixel is one kilohertz. + private static BandmapScale Scale() => + new(Frequency.FromKilohertz(14_000), Frequency.FromKilohertz(14_100), 100); + + private static Spot At(double kilohertz, string call = "DL1ABC") => + new(Callsign.Parse(call), Frequency.FromKilohertz(kilohertz), Now, SpotSource.Cluster); + + [Fact] + public void ALabelSitsCentredOnItsFrequency() + { + IReadOnlyList placed = BandmapLayout.Place([At(14_050)], Scale(), LabelHeight); + + Assert.Equal(50, placed[0].MarkY); + Assert.Equal(45, placed[0].LabelY); + } + + [Fact] + public void LabelsComeOutInFrequencyOrder() + { + IReadOnlyList placed = BandmapLayout.Place( + [At(14_080, "JA1XYZ"), At(14_010, "DL1ABC")], + Scale(), + LabelHeight); + + Assert.Equal(["DL1ABC", "JA1XYZ"], placed.Select(l => l.Spot.Call.Text)); + } + + /// Two stations a hundred hertz apart would have their text on top of each + /// other, so the lower one moves down and its leader line does the work. + [Fact] + public void ALabelThatWouldCoverTheOneAboveIsPushedDown() + { + IReadOnlyList placed = BandmapLayout.Place( + [At(14_050, "DL1ABC"), At(14_050.1, "JA1XYZ")], + Scale(), + LabelHeight); + + Assert.Equal(45, placed[0].LabelY); + Assert.Equal(55.5, placed[1].LabelY); + // the leader still points at where the station really is + Assert.Equal(50.1, placed[1].MarkY, 3); + } + + [Fact] + public void ALabelWithRoomKeepsItsOwnPlace() + { + IReadOnlyList placed = BandmapLayout.Place( + [At(14_010, "DL1ABC"), At(14_090, "JA1XYZ")], + Scale(), + LabelHeight); + + Assert.Equal(5, placed[0].LabelY); + Assert.Equal(85, placed[1].LabelY); + } + + [Fact] + public void AStationOutsideTheSliceIsNotDrawn() => + Assert.Empty(BandmapLayout.Place([At(14_200)], Scale(), LabelHeight)); + + [Fact] + public void TheTicksStartAtTheFirstRoundFrequencyInTheSlice() + { + IReadOnlyList ticks = BandmapLayout.Ticks( + new BandmapScale(Frequency.FromKilohertz(14_003), Frequency.FromKilohertz(14_012), 100), + Frequency.FromKilohertz(5)); + + Assert.Equal([14_005_000, 14_010_000], ticks.Select(t => t.Hertz)); + } + + [Fact] + public void TheScaleTurnsPixelsBackIntoFrequencies() => + Assert.Equal(14_050_000, Scale().At(50).Hertz); + + [Fact] + public void AReceiverBarIsAsWideAsTheModeItIsIn() + { + VfoMarker vfo = VfoMarker.Centred( + Frequency.FromKilohertz(14_025), + Frequency.FromHertz(500), + VfoRole.Receive); + + Assert.Equal(14_024_750, vfo.Low.Hertz); + Assert.Equal(14_025_250, vfo.High.Hertz); + } +}