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
338 lines
14 KiB
Plaintext
338 lines
14 KiB
Plaintext
@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, 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 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
|
||
|
||
@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>
|
||
@if (stats is { Error: null } live)
|
||
{
|
||
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @(live.Idle ? Localizer["idle"] : $"{live.Fps} fps")</span>
|
||
}
|
||
else if (stats?.Error is { Length: > 0 })
|
||
{
|
||
<span>@Localizer["The globe could not start."]</span>
|
||
}
|
||
else
|
||
{
|
||
<span>@Localizer["loading"]…</span>
|
||
}
|
||
</span>
|
||
</NerdTip>
|
||
</div>
|
||
</div>
|
||
|
||
@code {
|
||
[Inject] IJSRuntime JS { get; set; }
|
||
[Inject] InstanceService InstanceService { get; set; }
|
||
|
||
/// <summary>The id of the element that scrolls the feed's cards.</summary>
|
||
[Parameter, EditorRequired] public string ScrollContainerId { get; set; }
|
||
[Parameter] public List<FeedPost> Posts { get; set; } = [];
|
||
|
||
ElementReference element;
|
||
IJSObjectReference module;
|
||
DotNetObjectReference<GlobePanel> reference;
|
||
PeriodicTimer statsTimer;
|
||
readonly CancellationTokenSource cancellation = new();
|
||
string sentFingerprint;
|
||
GlobeStats stats;
|
||
|
||
protected override void OnInitialized() => IsLoading = false;
|
||
|
||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||
{
|
||
await base.OnAfterRenderAsync(firstRender);
|
||
try
|
||
{
|
||
if (firstRender)
|
||
{
|
||
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
|
||
reference = DotNetObjectReference.Create(this);
|
||
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference,
|
||
new { Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName });
|
||
statsTimer = new(TimeSpan.FromSeconds(1));
|
||
_ = ReadStats(cancellation.Token);
|
||
}
|
||
await SendPosts();
|
||
}
|
||
catch (JSDisconnectedException)
|
||
{
|
||
}
|
||
catch (Exception ex) when (ex is JSException or InvalidOperationException)
|
||
{
|
||
await CascadingState.ProcessError(ex, $"{nameof(GlobePanel)}.{nameof(OnAfterRenderAsync)}");
|
||
}
|
||
}
|
||
|
||
/// <summary>Sends the located posts when they changed since the last call.</summary>
|
||
async Task SendPosts()
|
||
{
|
||
if (module is null)
|
||
return;
|
||
|
||
var located = Posts.Where(p => p.Location is not null).ToList();
|
||
var self = InstanceService.Known(InstanceService.SelfHost);
|
||
var fingerprint = string.Join('|', located.Select(p => $"{p.Key}:{p.Location.Latitude}:{p.Location.Longitude}:{p.Location.CdnKey}")) + $"#{self?.Geo?.Latitude}";
|
||
if (fingerprint == sentFingerprint)
|
||
return;
|
||
sentFingerprint = fingerprint;
|
||
|
||
var points = located.Select(p =>
|
||
{
|
||
var server = InstanceService.Known(p.Host)?.Geo;
|
||
var before = p.Location.Before;
|
||
return new GlobePoint(p.Key, p.Location.HasPoint ? p.Location.Latitude : null, p.Location.HasPoint ? p.Location.Longitude : null,
|
||
p.Location.Source.ToString().ToLowerInvariant(), p.Location.Label, p.Host, server?.Latitude, server?.Longitude, p.Located ? p.RangeKm : null,
|
||
p.Location.CdnKey, before?.Latitude, before?.Longitude);
|
||
}).ToList();
|
||
await module.InvokeVoidAsync("setPosts", points, SelfPoint(self));
|
||
}
|
||
|
||
static GlobeSelf SelfPoint(ServerInfo self)
|
||
{
|
||
if (self?.Geo is { Latitude: { } latitude, Longitude: { } longitude } geo)
|
||
return new(latitude, longitude, $"PrivaPub · {geo.City ?? self.Host}", self.Host);
|
||
return CountryPoints.Of(self?.Geo?.Country) is { } country
|
||
? new(country.Latitude, country.Longitude, $"PrivaPub · {InstanceService.CountryName(self.Geo.Country)}", self.Host)
|
||
: 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
|
||
{
|
||
while (await statsTimer.WaitForNextTickAsync(cancellationToken))
|
||
{
|
||
stats = await module.InvokeAsync<GlobeStats>("stats", cancellationToken);
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
}
|
||
catch (JSDisconnectedException)
|
||
{
|
||
}
|
||
}
|
||
|
||
IEnumerable<NerdRow> StatsRows()
|
||
{
|
||
if (stats is null)
|
||
yield break;
|
||
yield return NerdRow.Of(Localizer["camera"], $"{Fixed(stats.Latitude)}, {Fixed(stats.Longitude)} @ {Fixed(stats.Altitude)}");
|
||
yield return NerdRow.Of(Localizer["in view"], $"{stats.VisibleLocated}/{stats.Visible} {Localizer["placed"]}");
|
||
yield return NerdRow.Of(Localizer["centre of view"], stats.CentroidLatitude is { } latitude ? $"{Fixed(latitude)}, {Fixed(stats.CentroidLongitude)}" : null);
|
||
yield return NerdRow.Of(Localizer["spread"], stats.SpreadKm > 0 ? $"{stats.SpreadKm:N0} km" : null);
|
||
yield return NerdRow.Of(Localizer["points"], $"{stats.Points} ({stats.Posts} {Localizer["posts"]}, {stats.Servers} {Localizer["servers"]})");
|
||
yield return NerdRow.Of(Localizer["CDN groups"], stats.CdnGroups > 0 ? stats.CdnGroups : null);
|
||
yield return NerdRow.Of(Localizer["hovered"], stats.Hovered is { } hovered ? $"{hovered} ({stats.Arcs} {Localizer["arcs"]}, {stats.Rings} {Localizer["rings"]})" : null);
|
||
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);
|
||
}
|
||
|
||
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)
|
||
{
|
||
await module.InvokeVoidAsync("dispose");
|
||
await module.DisposeAsync();
|
||
}
|
||
}
|
||
catch (JSDisconnectedException)
|
||
{
|
||
}
|
||
await base.DisposeAsync();
|
||
}
|
||
|
||
sealed record GlobePoint(string Key, double? Lat, double? Lng, string Src, string Label, string Host, double? ServerLat, double? ServerLng, double? Range,
|
||
string Cdn, double? BeforeLat, double? BeforeLng);
|
||
|
||
sealed record GlobeSelf(double Lat, double Lng, string Label, string Host);
|
||
|
||
sealed class GlobeStats
|
||
{
|
||
public double? Latitude { get; set; }
|
||
public double? Longitude { get; set; }
|
||
public double? Altitude { get; set; }
|
||
public int Visible { get; set; }
|
||
public int VisibleLocated { get; set; }
|
||
public double? CentroidLatitude { get; set; }
|
||
public double? CentroidLongitude { get; set; }
|
||
public double SpreadKm { get; set; }
|
||
public int Points { get; set; }
|
||
public int Posts { get; set; }
|
||
public int Servers { get; set; }
|
||
public int CdnGroups { get; set; }
|
||
public int Fps { get; set; }
|
||
public string Renderer { get; set; }
|
||
public string Texture { get; set; }
|
||
public int Anisotropy { get; set; }
|
||
public bool AutoRotate { get; set; }
|
||
public bool Paused { get; set; }
|
||
public bool Idle { get; set; }
|
||
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; }
|
||
}
|
||
}
|