Draw the grey line map

Window > Grey Line opens the world with the night on it. The dark area is
where the sun is down, and the band along its edge is the twilight from the
horizon to six degrees below it, which is the grey line an operator watches
for on the low bands.

Grayline works out where the sun stands: the subsolar point, the elevation at
a place, and the latitude the terminator crosses a meridian at, which is what
the map fills the night between. It is the same question SunTimes answers from
the other end, and the two are checked against each other in the tests.

The coastlines are Natural Earth's 110m coastline, public domain, cut to a
tenth of a degree.

Our own station is drawn from the grid square, the station being called and
the spots on the band from the country file. The map follows the clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-31 22:09:16 +00:00
co-authored by Claude Opus 5
parent c8de74e24a
commit 53c15d4061
11 changed files with 642 additions and 1 deletions
@@ -0,0 +1,100 @@
using Avalonia.Media;
using Avalonia.Threading;
using Nonemm.Core;
using Nonemm.Core.Country;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// N1MM's grey line window: the world with the night drawn on it, our own
/// station on it, and the stations being called and spotted. It follows the
/// clock, redrawing every minute.
public sealed partial class GraylineWindow : RefreshableWindow
{
private static readonly IBrush HomeColour = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xFF));
private static readonly IBrush CallColour = new SolidColorBrush(Color.FromRgb(0xFF, 0x6B, 0x4A));
private static readonly IBrush SpotColour = new SolidColorBrush(Color.FromRgb(0x6A, 0xC8, 0xFF));
private readonly AppSession session;
private readonly DispatcherTimer clock;
public GraylineWindow(AppSession session)
{
this.session = session;
InitializeComponent();
clock = new DispatcherTimer { Interval = TimeSpan.FromMinutes(1) };
clock.Tick += (_, _) => Refresh();
clock.Start();
session.Changed += WhenSessionChanged;
Closed += (_, _) =>
{
clock.Stop();
session.Changed -= WhenSessionChanged;
};
Refresh();
}
private OperatingPosition? Radio => session.Position;
public override void Refresh()
{
Map.Moment = DateTime.UtcNow;
Map.Markers = Markers();
Map.InvalidateVisual();
}
private IReadOnlyList<MapMarker> Markers()
{
List<MapMarker> markers = [];
if (GridSquare.TryParse(session.Settings.Station.GridSquare, out GridSquare home))
{
markers.Add(new MapMarker(
session.Settings.Station.Callsign,
home.Latitude,
home.Longitude,
HomeColour));
}
foreach (Station station in Stations())
{
markers.Add(new MapMarker(station.Call, station.Latitude, station.Longitude, station.Colour));
}
return markers;
}
/// The station being called, and every station spotted on the band we are
/// on. Both are placed by the country file, which holds a position for each
/// country: it is the middle of the country rather than the station itself,
/// which is as much as a callsign says.
private IReadOnlyList<Station> Stations()
{
List<Station> found = [];
if (Radio is { } position && Where(position.Entry.Call) is { } called)
{
found.Add(new Station(
position.Entry.Call.Trim().ToUpperInvariant(),
called.Latitude,
called.Longitude,
CallColour));
}
if (Radio is { } here && Bands.ForFrequency(here.Frequency) is { } band)
{
foreach (Spotting.Spot spot in session.Bandmap.On(band).Where(s => s.IsStation))
{
string call = spot.Call.Text;
if (found.All(s => s.Call != call) && Where(call) is { } place)
{
found.Add(new Station(call, place.Latitude, place.Longitude, SpotColour));
}
}
}
return found;
}
private CountryLookup? Where(string call) =>
call.Trim().Length == 0 ? null : session.Countries?.Find(call.Trim());
private void WhenSessionChanged(object? sender, EventArgs e) =>
Dispatcher.UIThread.Post(Refresh);
private sealed record Station(string Call, double Latitude, double Longitude, IBrush Colour);
}