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
parent c8de74e24a
commit 53c15d4061
11 changed files with 642 additions and 1 deletions

View File

@@ -47,7 +47,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points.
| Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), WAE (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, OK/OM DX (CW), YOTA, IOTA, EU DX Contest, CQ 160, ARRL 10M, REF, Ukrainian DX, Russian DX RTTY, RDAC, YO DX HF, Oceania DX, All Asian, SARTG RTTY, BARTG Sprint, Mexico RTTY, IARU Region 1 Field Day, general logging, and user-defined `.udc` contests | | Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), WAE (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, OK/OM DX (CW), YOTA, IOTA, EU DX Contest, CQ 160, ARRL 10M, REF, Ukrainian DX, Russian DX RTTY, RDAC, YO DX HF, Oceania DX, All Asian, SARTG RTTY, BARTG Sprint, Mexico RTTY, IARU Region 1 Field Day, general logging, and user-defined `.udc` contests |
| Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out | | Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out |
| While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file | | While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file |
| Windows | entry, log, check, bandmap, available mults and Qs, score summary, telnet, digital interface | | Windows | entry, log, check, bandmap, available mults and Qs, score summary, grey line, telnet, digital interface |
| 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 | | 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 | one or two radios over hamlib `rigctld`, split, reconnecting on its own | | Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own |
| Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station | | Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station |
@@ -382,6 +382,24 @@ otherwise, which puts the station in the middle of its country. Sunrise and
sunset are worked out from that position for today; inside the polar circles the sunset are worked out from that position for today; inside the polar circles the
line says the sun does not rise or set. line says the sun does not rise or set.
### The grey line
**Window → Grey Line** draws the world with the night on it. The dark area is
where the sun is down; the band along its edge is the twilight between the
horizon and six degrees below it, which is the grey line itself — the hour when
the low bands carry furthest along it.
On the map: our own station, from the grid square in the station settings; the
station being called; and every station spotted on the band the radio is on.
The yellow dot is the point the sun is straight above. The map follows the
clock, redrawing every minute. Sunrise and sunset for the station being called
are on the line under the entry window, which is where N1MM puts them too.
The coastlines are Natural Earth's 110m coastline, which is public domain, cut
to a tenth of a degree: `src/Nonemm.App/Assets/coastline.txt`. The sun's
position is the standard low-precision solar formula, good to about a hundredth
of a degree.
### The log window ### The log window
The pane under the log lists what has already been worked with the call being The pane under the log lists what has already been worked with the call being

File diff suppressed because one or more lines are too long

View File

@@ -18,6 +18,10 @@
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<!-- MMTTY and the bridge are distributed with the program and are not in the <!-- MMTTY and the bridge are distributed with the program and are not in the
repository. Copying them beside the build is what fills the digital repository. Copying them beside the build is what fills the digital
engine and bridge paths in. --> engine and bridge paths in. -->

View File

@@ -428,6 +428,10 @@ public sealed partial class EntryWindow
private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session)); private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
/// N1MM's grey line window: where the daylight is now, and where it will be.
private void OnShowGrayline(object? sender, RoutedEventArgs e) =>
Show(() => new GraylineWindow(session));
private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune)); private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune));
/// N1MM's digital interface window, which is where RTTY is worked from. /// N1MM's digital interface window, which is where RTTY is worked from.

View File

@@ -118,6 +118,7 @@
<MenuItem Header="Call Stack" Click="OnShowCallStack" /> <MenuItem Header="Call Stack" Click="OnShowCallStack" />
<MenuItem Header="Check" Click="OnShowCheck" /> <MenuItem Header="Check" Click="OnShowCheck" />
<MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" /> <MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" />
<MenuItem Header="Grey Line" Click="OnShowGrayline" />
<MenuItem Header="Score Summary" Click="OnShowScore" /> <MenuItem Header="Score Summary" Click="OnShowScore" />
<MenuItem Header="Telnet" Click="OnShowTelnet" /> <MenuItem Header="Telnet" Click="OnShowTelnet" />
<MenuItem Header="Digital Interface" Click="OnShowDigital" /> <MenuItem Header="Digital Interface" Click="OnShowDigital" />

View File

@@ -0,0 +1,153 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Nonemm.Core;
namespace Nonemm.App.Windows;
/// A station drawn on the map.
public sealed record MapMarker(string Label, double Latitude, double Longitude, IBrush Colour);
/// The world with the night on it. The map is plate carrée — longitude across,
/// latitude down, both in a straight line — which is the projection N1MM's grey
/// line window uses and the only one where the terminator can be drawn a
/// meridian at a time.
public sealed class GraylineView : Control
{
/// One column per degree of longitude. The terminator is a smooth curve, so
/// this is as fine as a map of any size needs.
private const double ColumnDegrees = 1;
private static readonly IBrush Sea = new SolidColorBrush(Color.FromRgb(0x1B, 0x33, 0x4A));
private static readonly IBrush Land = new SolidColorBrush(Color.FromRgb(0x8F, 0xB8, 0x96));
private static readonly IBrush Night = new SolidColorBrush(Colors.Black, 0.55);
private static readonly IBrush Twilight = new SolidColorBrush(Color.FromRgb(0xE8, 0x7E, 0x1A), 0.35);
private static readonly IBrush Graticule = new SolidColorBrush(Colors.White, 0.15);
private static readonly IBrush Sun = new SolidColorBrush(Color.FromRgb(0xFF, 0xD7, 0x00));
private readonly Typeface typeface = new(new FontFamily("monospace"));
/// The moment the map is drawn for, which is now unless the operator has
/// stepped it forward to see where the grey line will be.
public DateTime Moment { get; set; } = DateTime.UtcNow;
public IReadOnlyList<MapMarker> Markers { get; set; } = [];
public override void Render(DrawingContext context)
{
Rect area = new(Bounds.Size);
context.FillRectangle(Sea, area);
DrawCoastlines(context);
DrawNight(context);
DrawGraticule(context);
DrawSun(context);
foreach (MapMarker marker in Markers)
{
DrawMarker(context, marker);
}
}
private void DrawCoastlines(DrawingContext context)
{
Pen pen = new(Land, 1);
foreach (IReadOnlyList<Point> line in WorldMap.Coastlines)
{
for (int at = 1; at < line.Count; at++)
{
Point from = line[at - 1];
Point to = line[at];
// a line that runs off one edge and back on at the other is the
// date line being crossed, and is left undrawn
if (Math.Abs(to.X - from.X) > 180)
{
continue;
}
context.DrawLine(pen, At(from.Y, from.X), At(to.Y, to.X));
}
}
}
/// The night is one shape, its edge the terminator: the latitude where the
/// sun stands on the horizon, meridian by meridian. The band from there
/// down to civil twilight is drawn over it in the grey line's own colour,
/// which is the part an operator is watching for.
private void DrawNight(DrawingContext context)
{
double darkPole = Grayline.IsNorthPoleLit(Moment) ? -90 : 90;
context.DrawGeometry(Night, null, Band(Grayline.Horizon, null, darkPole));
context.DrawGeometry(
Twilight,
null,
Band(Grayline.CivilTwilight, Grayline.Horizon, darkPole));
}
/// The area between the lines the sun stands at two elevations on. A null
/// `above` means the area runs to the dark pole instead, which is the night
/// itself.
private Geometry Band(double below, double? above, double darkPole)
{
StreamGeometry shape = new();
using StreamGeometryContext path = shape.Open();
path.BeginFigure(At(EdgeAt(-180, below, darkPole), -180), isFilled: true);
for (double longitude = -180 + ColumnDegrees; longitude <= 180; longitude += ColumnDegrees)
{
path.LineTo(At(EdgeAt(longitude, below, darkPole), longitude));
}
for (double longitude = 180; longitude >= -180; longitude -= ColumnDegrees)
{
double edge = above is { } elevation ? EdgeAt(longitude, elevation, darkPole) : darkPole;
path.LineTo(At(edge, longitude));
}
path.EndFigure(isClosed: true);
return shape;
}
/// The latitude on this meridian where the sun stands at that elevation.
/// Where it never does, the whole meridian is on one side of the line: the
/// edge is then the dark pole, which leaves nothing filled, or the lit one,
/// which fills the meridian from end to end.
private double EdgeAt(double longitude, double elevation, double darkPole) =>
Grayline.LatitudeAt(longitude, Moment, elevation)
?? (Grayline.Elevation(0, longitude, Moment) > elevation ? darkPole : -darkPole);
private void DrawGraticule(DrawingContext context)
{
Pen pen = new(Graticule, 1);
for (double longitude = -180; longitude <= 180; longitude += 30)
{
context.DrawLine(pen, At(90, longitude), At(-90, longitude));
}
for (double latitude = -60; latitude <= 60; latitude += 30)
{
context.DrawLine(pen, At(latitude, -180), At(latitude, 180));
}
}
private void DrawSun(DrawingContext context)
{
(double latitude, double longitude) = Grayline.SubsolarPoint(Moment);
context.DrawEllipse(Sun, null, At(latitude, longitude), 4, 4);
}
private void DrawMarker(DrawingContext context, MapMarker marker)
{
Point where = At(marker.Latitude, marker.Longitude);
context.DrawEllipse(marker.Colour, new Pen(Brushes.White, 1), where, 3.5, 3.5);
FormattedText label = new(
marker.Label,
CultureInfo.InvariantCulture,
FlowDirection.LeftToRight,
typeface,
11,
marker.Colour);
// the label goes to the left of a station near the right-hand edge, so
// it stays on the map
double x = where.X + 6 + label.Width > Bounds.Width ? where.X - 6 - label.Width : where.X + 6;
context.DrawText(label, new Point(x, where.Y - (label.Height / 2)));
}
private Point At(double latitude, double longitude) => new(
(longitude + 180) / 360.0 * Bounds.Width,
(90 - latitude) / 180.0 * Bounds.Height);
}

View File

@@ -0,0 +1,7 @@
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Nonemm.App.Windows"
x:Class="Nonemm.App.Windows.GraylineWindow"
Title="Grey line" Width="760" Height="420">
<local:GraylineView Name="Map" />
</local:RefreshableWindow>

View File

@@ -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);
}

View File

@@ -0,0 +1,45 @@
using System.Globalization;
using Avalonia;
using Avalonia.Platform;
namespace Nonemm.App.Windows;
/// The coastlines the grey line map is drawn on, read once from
/// `Assets/coastline.txt`. Each polyline is a run of points in degrees, X
/// longitude and Y latitude, which is what the map projects.
public static class WorldMap
{
private static readonly Lazy<IReadOnlyList<IReadOnlyList<Point>>> Loaded = new(Read);
public static IReadOnlyList<IReadOnlyList<Point>> Coastlines => Loaded.Value;
private static IReadOnlyList<IReadOnlyList<Point>> Read()
{
using Stream file = AssetLoader.Open(new Uri("avares://Nonemm.App/Assets/coastline.txt"));
using StreamReader text = new(file);
List<IReadOnlyList<Point>> lines = [];
while (text.ReadLine() is { } line)
{
if (line.StartsWith('#') || line.Trim().Length == 0)
{
continue;
}
List<Point> points = [];
foreach (string pair in line.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
string[] parts = pair.Split(',');
if (parts.Length == 2
&& double.TryParse(parts[0], CultureInfo.InvariantCulture, out double longitude)
&& double.TryParse(parts[1], CultureInfo.InvariantCulture, out double latitude))
{
points.Add(new Point(longitude, latitude));
}
}
if (points.Count > 1)
{
lines.Add(points);
}
}
return lines;
}
}

View File

@@ -0,0 +1,98 @@
namespace Nonemm.Core;
/// Where the sun stands, for the grey line map. The equations are the standard
/// low-precision solar position, good to about a hundredth of a degree, which
/// is far closer than a map a few hundred pixels wide can draw.
///
/// `SunTimes` answers when the sun rises and sets at one place; this answers
/// where the daylight is at one moment, which is the other half of the same
/// question.
public static class Grayline
{
/// The sun's centre at the horizon, allowing for its radius and for
/// refraction. The same figure `SunTimes` rises and sets on.
public const double Horizon = -0.833;
/// The sun six degrees down. Between this and the horizon is the grey line
/// itself: the band of twilight where the low bands carry furthest.
public const double CivilTwilight = -6.0;
/// The point the sun is straight above.
public static (double Latitude, double Longitude) SubsolarPoint(DateTime utc)
{
double days = DaysSince2000(utc);
double meanLongitude = 280.460 + (0.9856474 * days);
double anomaly = Radians(357.528 + (0.9856003 * days));
double ecliptic = Radians(
meanLongitude + (1.915 * Math.Sin(anomaly)) + (0.020 * Math.Sin(2 * anomaly)));
double obliquity = Radians(23.439 - (0.0000004 * days));
double declination = Math.Asin(Math.Sin(obliquity) * Math.Sin(ecliptic));
double rightAscension = Math.Atan2(
Math.Cos(obliquity) * Math.Sin(ecliptic),
Math.Cos(ecliptic));
double siderealTime = 280.46061837 + (360.98564736629 * days);
return (Degrees(declination), Wrap(Degrees(rightAscension) - siderealTime));
}
/// How high the sun stands at a place, in degrees. Negative is below the
/// horizon.
public static double Elevation(double latitude, double longitude, DateTime utc)
{
(double declination, double subsolar) = SubsolarPoint(utc);
double hourAngle = Radians(longitude - subsolar);
double sine = (Math.Sin(Radians(latitude)) * Math.Sin(Radians(declination)))
+ (Math.Cos(Radians(latitude)) * Math.Cos(Radians(declination)) * Math.Cos(hourAngle));
return Degrees(Math.Asin(Math.Clamp(sine, -1, 1)));
}
public static bool IsDaylight(double latitude, double longitude, DateTime utc) =>
Elevation(latitude, longitude, utc) > Horizon;
/// The latitude on this meridian where the sun stands at `elevation`, which
/// is the point the terminator crosses it. Null where the sun never reaches
/// that height on the meridian at all, which is the polar day and the polar
/// night: then every latitude on it is on the same side of the line.
public static double? LatitudeAt(double longitude, DateTime utc, double elevation = Horizon)
{
(double declination, double subsolar) = SubsolarPoint(utc);
double hourAngle = Radians(longitude - subsolar);
// sin h = sin φ sin δ + cos φ cos δ cos H is a sine wave in φ, so it is
// solved rather than searched for
double alongPole = Math.Sin(Radians(declination));
double alongEquator = Math.Cos(Radians(declination)) * Math.Cos(hourAngle);
double size = Math.Sqrt((alongPole * alongPole) + (alongEquator * alongEquator));
double wanted = Math.Sin(Radians(elevation));
if (size == 0 || Math.Abs(wanted / size) > 1)
{
return null;
}
double phase = Math.Atan2(alongEquator, alongPole);
double latitude = Degrees(Math.Asin(wanted / size) - phase);
return latitude is >= -90 and <= 90 ? latitude : null;
}
/// True when the pole is in daylight, which says which side of the
/// terminator the night is on.
public static bool IsNorthPoleLit(DateTime utc) => SubsolarPoint(utc).Latitude > 0;
private static double DaysSince2000(DateTime utc) =>
(utc.ToUniversalTime() - new DateTime(2000, 1, 1, 12, 0, 0, DateTimeKind.Utc)).TotalDays;
private static double Wrap(double degrees)
{
double turned = degrees % 360.0;
if (turned > 180)
{
turned -= 360;
}
if (turned < -180)
{
turned += 360;
}
return turned;
}
private static double Radians(double degrees) => degrees * Math.PI / 180.0;
private static double Degrees(double radians) => radians * 180.0 / Math.PI;
}

View File

@@ -0,0 +1,73 @@
using Nonemm.Core;
namespace Nonemm.Core.Tests;
/// Where the sun stands, checked against what the calendar says it must be.
public class GraylineTests
{
[Fact]
public void TheSunIsOverTheTropicAtTheSolstice()
{
(double latitude, _) = Grayline.SubsolarPoint(new DateTime(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc));
Assert.Equal(23.4, latitude, 0.1);
}
[Fact]
public void TheSunIsOverTheEquatorAtTheEquinox()
{
(double latitude, _) = Grayline.SubsolarPoint(new DateTime(2026, 3, 20, 14, 46, 0, DateTimeKind.Utc));
Assert.Equal(0, latitude, 0.1);
}
/// At noon UTC the sun is over Greenwich, give or take the quarter of an
/// hour the equation of time moves it by.
[Fact]
public void TheSunIsOverGreenwichAtNoon()
{
(_, double longitude) = Grayline.SubsolarPoint(new DateTime(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc));
Assert.Equal(0, longitude, 1.0);
}
/// The two ways of asking have to agree: at the moment `SunTimes` says the
/// sun rises, it is on the horizon.
[Fact]
public void SunriseAgreesWithTheSunStandingOnTheHorizon()
{
(DateTime rise, DateTime set) =
SunTimes.For(48.15, 17.11, new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc))!.Value;
Assert.Equal(Grayline.Horizon, Grayline.Elevation(48.15, 17.11, rise), 0.3);
Assert.Equal(Grayline.Horizon, Grayline.Elevation(48.15, 17.11, set), 0.3);
}
[Fact]
public void MiddayIsDaylightAndMidnightIsNot()
{
DateTime noon = new(2026, 8, 31, 12, 0, 0, DateTimeKind.Utc);
Assert.True(Grayline.IsDaylight(0, 0, noon));
Assert.False(Grayline.IsDaylight(0, 180, noon));
}
/// The terminator crosses every meridian once between the poles while the
/// sun is over the equator, and the polar day has no crossing at all.
[Fact]
public void TheTerminatorCrossesAMeridianWhereTheSunIsOnTheHorizon()
{
DateTime moment = new(2026, 8, 31, 12, 0, 0, DateTimeKind.Utc);
double crossing = Grayline.LatitudeAt(30, moment)!.Value;
Assert.Equal(Grayline.Horizon, Grayline.Elevation(crossing, 30, moment), 0.3);
}
[Fact]
public void TheNorthPoleIsLitInJuneAndDarkInDecember()
{
Assert.True(Grayline.IsNorthPoleLit(new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc)));
Assert.False(Grayline.IsNorthPoleLit(new DateTime(2026, 12, 21, 0, 0, 0, DateTimeKind.Utc)));
}
}