Draw the bandmap the way N1MM draws it
The bandmap was a list of frequencies and callsigns. It is now a frequency scale with the stations written out beside it and a leader line from each callsign back to where the station really is. BandmapLayout does the placing and has no UI reference, so the rule is unit tested: put the label centred on its frequency, and if it would cover the one above, push it below instead. That is N1MM's CalculateOffset, and it is what keeps three stations a hundred hertz apart readable. The scale stops short of the top and bottom edges, or the first and last frequency numbers come out cut in half. VfoMarker draws the receiver as a bar as wide as the mode passes, rather than a line. Only the receiver is fed: the radio does not report a transmit VFO or a second radio yet, so VfoRole has the other two roles ready and nothing draws them. Band-plan colouring of the scale is left out because the segments differ by ITU region and there is no band-plan table to read them from. Checked by running the program against a fake cluster node under Xvfb and looking at the result: twelve spots, three of them stacked with fanned leaders. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
142
src/Nonemm.App/Windows/BandmapView.cs
Normal file
142
src/Nonemm.App/Windows/BandmapView.cs
Normal file
@@ -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<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;
|
||||
|
||||
/// 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));
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="6,6,6,2" Spacing="6">
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="6,6,6,4" Spacing="8">
|
||||
<TextBlock Name="BandText" FontSize="12" VerticalAlignment="Center" />
|
||||
<CheckBox Name="ThisBandOnly" Content="this band" IsChecked="True" FontSize="11" />
|
||||
<ComboBox Name="SpanBox" FontSize="11" Width="96" />
|
||||
</StackPanel>
|
||||
<ListBox Name="Spots" FontFamily="monospace" FontSize="13" />
|
||||
<TextBlock DockPanel.Dock="Bottom" Name="StatusText" Margin="6,2,6,6"
|
||||
FontSize="11" Opacity="0.7" />
|
||||
<local:BandmapView Name="View" />
|
||||
</DockPanel>
|
||||
</local:RefreshableWindow>
|
||||
|
||||
@@ -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<Frequency, string> 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<Spot> 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<VfoMarker> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user