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

File diff suppressed because one or more lines are too long

View File

@@ -18,6 +18,10 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<!-- 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
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));
/// 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));
/// 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="Check" Click="OnShowCheck" />
<MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" />
<MenuItem Header="Grey Line" Click="OnShowGrayline" />
<MenuItem Header="Score Summary" Click="OnShowScore" />
<MenuItem Header="Telnet" Click="OnShowTelnet" />
<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;
}