The globe under the Sun of now or of a post, with borders and country names
Build / Build (push) Successful in 46s
Deploy / decepub.thepra.dev (push) Successful in 1m52s

The Sun lights the globe where it really is (wwwroot/js/sun.js, the Astronomical Almanac's low-precision solar
coordinates): the day map shaded by the Sun's height and the Earth–Sun distance, warm city lights on the night side,
and a twilight blend about 13° wide between them. It shows now, refreshed every 30 s, or the moment of the hovered,
focused or pinned post: a click on a card pins it, and the subsolar point travels there the shorter way round. The
panel's top corner shows that moment, the phase of the day and solar time at the post's place, and the season in its
hemisphere (or in both, for a post without a place). Its tooltip shows the subsolar point, declination, axial tilt,
distance, irradiance, day length and the next equinox or solstice.

Land borders are thin paths. Country names are in the reader's language and appear as the camera comes closer:
largest countries first, away from the globe's edge, never overlapping. They come from world-atlas 2.0.2 (Natural
Earth 1:50m), prepared by tools/globe/prepare-countries.py, and are vendored with their hashes in SOURCES.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-04 20:23:42 +02:00
1 parent 69d0a641fe
commit 0f68e74e38
13 files changed
+1066 -50

No files matched your search

+152 -6
View File
@@ -1,12 +1,39 @@
@inherits LocalizableComponentBase
@using System.Globalization
@* The feed's 3D globe (wwwroot/js/globe.js on the vendored globe.gl): it looks at the average place of the posts in
view and shows where a hovered post is. The posts' places go to the module in one call whenever they change; the
corner shows the camera and the frame rate, its tooltip the rest of the globe's numbers. *@
view and shows where a hovered post is, lit by the Sun of now or of the moment the hovered, focused or pinned post
was written. The posts' places go to the module in one call whenever they change; the module calls SunChanged when
the Sun's moment changes. The top corner shows that moment, the phase of the day and the season (its tooltip the
Sun's numbers); the bottom corner the camera and the frame rate, its tooltip the rest of the globe's numbers. The
globe is isolated: its labels carry z-indexes by depth, which must not reach the corners' tooltips. *@
<div class="relative w-full h-full">
<div @ref="element" class="absolute inset-0" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
<div class="absolute left-2 bottom-2 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
@if (stats?.Sun is { } sun)
{
<div class="absolute left-2 top-2 z-10 max-w-[75%] text-[11px] leading-tight pointer-events-auto">
<NerdTip Heading="@Localizer["Sun"]" Rows="SunRows(sun)" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.Start">
<div class="flex flex-col gap-0.5 rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs">
<span class="flex items-center gap-1 font-semibold">
<i class="@SunIcon(sun)"></i>
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
</span>
@if (sun.Phase is { } phase)
{
<span class="truncate">@sun.Place · @PhaseName(phase, sun.Morning) · @Localizer["solar time {0}", sun.SolarTime]</span>
<span>@Localizer[SeasonName(sun.Season)] · @Localizer[sun.Hemisphere == "south" ? "southern hemisphere" : "northern hemisphere"]</span>
}
else
{
<span>@Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]]</span>
}
</div>
</NerdTip>
</div>
}
<div class="absolute left-2 bottom-2 z-10 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
<span class="badge badge-ghost badge-xs gap-1">
<i class="ion-md-planet"></i>
@@ -37,6 +64,7 @@
ElementReference element;
IJSObjectReference module;
DotNetObjectReference<GlobePanel> reference;
PeriodicTimer statsTimer;
readonly CancellationTokenSource cancellation = new();
string sentFingerprint;
@@ -52,7 +80,9 @@
if (firstRender)
{
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
await module.InvokeVoidAsync("create", element, ScrollContainerId);
reference = DotNetObjectReference.Create(this);
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference,
new { Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName });
statsTimer = new(TimeSpan.FromSeconds(1));
_ = ReadStats(cancellation.Token);
}
@@ -100,6 +130,26 @@
: null;
}
/// <summary>The globe's Sun moved to another moment (now, or a post's): the overlay follows at once.</summary>
[JSInvokable]
public async Task SunChanged()
{
if (module is null)
return;
try
{
stats = await module.InvokeAsync<GlobeStats>("stats", cancellation.Token);
StateHasChanged();
}
catch (OperationCanceledException)
{
}
catch (JSDisconnectedException)
{
}
}
async Task ReadStats(CancellationToken cancellationToken)
{
try
@@ -132,17 +182,82 @@
yield return NerdRow.Of("fps", stats.Paused ? Localizer["paused"] : stats.Idle ? Localizer["idle: nothing moves, nothing is drawn"] : stats.Fps.ToString());
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048, {stats.Anisotropy}× anisotropic" : null);
yield return NerdRow.Of(Localizer["countries"], stats.Countries > 0 ? $"{stats.Countries} ({stats.CountryNames} {Localizer["named at this zoom"]})" : null);
yield return NerdRow.Of(Localizer["borders"], stats.Borders > 0 ? $"{stats.Borders} {Localizer["lines"]}" : null);
yield return NerdRow.Of("WebGL", stats.Renderer);
yield return NerdRow.Of(Localizer["error"], stats.Error);
}
static string Fixed(double? value) => value?.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture) ?? "–";
IEnumerable<NerdRow> SunRows(SunStats sun)
{
yield return NerdRow.Of(Localizer["moment"], DateTime.Parse(sun.At, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal));
yield return NerdRow.Of(Localizer["subsolar point"], $"{Fixed(sun.Latitude)}, {Fixed(sun.Longitude)}");
yield return NerdRow.Of(Localizer["declination"], $"{Fixed(sun.Latitude)}°");
yield return NerdRow.Of(Localizer["axial tilt"], $"{Fixed(sun.Obliquity)}°");
yield return NerdRow.Of(Localizer["ecliptic longitude"], $"{Fixed(sun.EclipticLongitude)}°");
yield return NerdRow.Of(Localizer["Earth–Sun distance"],
$"{sun.Distance.ToString("0.00000", CultureInfo.InvariantCulture)} AU ({sun.Distance * 149_597_870.7:N0} km)");
yield return NerdRow.Of(Localizer["irradiance"],
$"{sun.Irradiance * 1361:0} W/m² ({Math.Round((sun.Irradiance - 1) * 100, 1).ToString("+0.0;-0.0;0.0", CultureInfo.InvariantCulture)}%)");
yield return NerdRow.Of(Localizer["Place"], sun.Place is { } place ? $"{place} ({Fixed(sun.PlaceLatitude)}, {Fixed(sun.PlaceLongitude)})" : null);
yield return NerdRow.Of(Localizer["elevation"], sun.Elevation is { } elevation ? $"{Fixed(elevation)}°" : null);
yield return NerdRow.Of(Localizer["day length"], sun.DayLength is { } hours ? NerdRow.Duration(TimeSpan.FromHours(hours)) : null);
yield return NerdRow.Of(Localizer["seasons"], $"{Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]]}");
yield return NerdRow.Of(Localizer["next"], sun.NextEvent is { } next ? Localizer["{0} in {1} days", Localizer[EventName(next)], sun.NextEventDays] : null);
}
static string Fixed(double? value) => value?.ToString("0.##", CultureInfo.InvariantCulture) ?? "–";
/// <summary>The Sun's moment in the reader's time zone.</summary>
static string LocalTime(string at) =>
DateTimeOffset.TryParse(at, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var time)
? time.ToLocalTime().ToString(SUtility.dateTimeFormatExt, CultureInfo.InvariantCulture)
: at;
static string SunIcon(SunStats sun) => sun.Phase switch
{
"day" => "ion-md-sunny",
"golden" => "ion-md-partly-sunny",
"civil" or "nautical" or "astronomical" => "ion-md-cloudy-night",
"night" => "ion-md-moon",
_ => "ion-md-time"
};
string PhaseName(string phase, bool? morning) => phase switch
{
"day" => Localizer["daylight"],
"night" => Localizer["night"],
_ => Localizer["{0}, {1}", Localizer[phase switch
{
"golden" => "golden hour",
"civil" => "civil twilight",
"nautical" => "nautical twilight",
_ => "astronomical twilight"
}], Localizer[morning == true ? "morning" : "evening"]]
};
static string SeasonName(string season) => season switch
{
"spring" => "spring",
"summer" => "summer",
"autumn" => "autumn",
_ => "winter"
};
static string EventName(string next) => next switch
{
"march-equinox" => "March equinox",
"june-solstice" => "June solstice",
"september-equinox" => "September equinox",
_ => "December solstice"
};
public override async ValueTask DisposeAsync()
{
await cancellation.CancelAsync();
cancellation.Dispose();
statsTimer?.Dispose();
reference?.Dispose();
try
{
if (module is not null)
@@ -186,6 +301,37 @@
public string Hovered { get; set; }
public int Arcs { get; set; }
public int Rings { get; set; }
public int Countries { get; set; }
public int CountryNames { get; set; }
public int Borders { get; set; }
public SunStats Sun { get; set; }
public string Error { get; set; }
}
/// <summary>The Sun of the globe's moment (wwwroot/js/sun.js); the place fields are null without a focused located post.</summary>
sealed class SunStats
{
public string At { get; set; }
public bool Now { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
public double Distance { get; set; }
public double Irradiance { get; set; }
public double EclipticLongitude { get; set; }
public double Obliquity { get; set; }
public string Place { get; set; }
public double? PlaceLatitude { get; set; }
public double? PlaceLongitude { get; set; }
public double? Elevation { get; set; }
public string Phase { get; set; }
public bool? Morning { get; set; }
public string SolarTime { get; set; }
public double? DayLength { get; set; }
public string Season { get; set; }
public string Hemisphere { get; set; }
public string SeasonNorth { get; set; }
public string SeasonSouth { get; set; }
public string NextEvent { get; set; }
public int? NextEventDays { get; set; }
}
}