Files
decePubClient/Components/Feed/GlobePanel.razor
T

361 lines
14 KiB
Plaintext
Raw Normal View History

@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. Its nerd stats are drawn in the scene (wwwroot/js/instruments.js: the Sun, the day/night line, the place,
the view, the year, an orrery, the frame rate as a ring), in the reader's language (InstrumentKeys); the caption at
the top says the moment, and a screen reader gets the Sun's numbers and the view as text. The posts' places go to the
module in one call whenever they change; the module calls SunChanged when the Sun's moment changes, and nothing is
polled. The globe is isolated: its labels carry z-indexes by depth. Below lg the globe is a banner the page shows
above the feed: a tap opens it full screen (Expanded, which the page lays out), and its close button or Escape
brings the feed back. *@
<div class="relative w-full h-full" tabindex="-1" @onkeydown="OnKeyDown" data-globe-state="@State">
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"
aria-describedby="@($"{uid}-sun {uid}-view")"></div>
@* what the instruments draw, as text for a screen reader: the Sun's numbers, and the view (globe.js writes it) *@
<dl id="@($"{uid}-sun")" class="sr-only">
@if (sun is not null)
{
@foreach (var row in SunRows(sun).Where(row => !string.IsNullOrEmpty(row.Value)))
{
<dt>@row.Label</dt>
<dd>@row.Value</dd>
}
}
</dl>
<p id="@($"{uid}-view")" class="sr-only" data-globe-view-description></p>
@* the moment the Sun is shown at: now, or the focused post's *@
@if (sun is not null)
{
<p class="absolute left-1/2 -translate-x-1/2 top-2 z-10 max-w-[70%] truncate text-[11px] pointer-coarse:text-xs leading-tight rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs pointer-events-none"
data-globe-moment>
<i class="@SunIcon(sun)" aria-hidden="true"></i>
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
</p>
}
else if (error is { Length: > 0 })
{
<p class="absolute left-2 bottom-2 z-10 text-xs rounded-box px-2 py-1 bg-base-100/70" role="status">@Localizer["The globe could not start."]</p>
}
@if (Expanded)
{
<button @ref="closeButton" type="button" class="btn btn-circle btn-sm absolute right-3 top-[max(0.75rem,env(safe-area-inset-top))] z-20"
aria-label="@Localizer["Close"]" title="@Localizer["Close"]" @onclick="Close" data-globe-close>
<i class="ion-md-close" aria-hidden="true"></i>
</button>
}
else
{
<button @ref="expandButton" type="button" class="absolute inset-0 z-20 lg:hidden cursor-zoom-in" aria-label="@Localizer["Open the globe full screen"]"
title="@Localizer["Open the globe full screen"]" @onclick="Open" data-globe-expand>
<i class="ion-md-expand absolute right-3 bottom-3 text-lg rounded-full bg-base-100/70 p-1" aria-hidden="true"></i>
</button>
}
</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; } = [];
/// <summary>Open full screen (a phone's banner tapped); the page lays the globe out over everything.</summary>
[Parameter] public bool Expanded { get; set; }
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
2026-10-06 23:49:49 +02:00
[Inject] OverlayService Overlays { get; set; }
ElementReference closeButton;
ElementReference expandButton;
2026-10-06 23:49:49 +02:00
IDisposable overlay;
bool focusClose;
bool focusExpand;
string State => error is { Length: > 0 } ? "error" : sun is null ? "loading" : "live";
// what the instruments drawn in the scene say (wwwroot/js/instruments.js), in the reader's language
static readonly string[] InstrumentKeys =
[
"subsolar point", "declination", "axial tilt", "irradiance", "elevation", "day length",
"golden hour", "civil twilight", "nautical twilight", "astronomical twilight", "daylight", "night", "morning", "evening",
"spring", "summer", "autumn", "winter", "March equinox", "June solstice", "September equinox", "December solstice",
"{0} in {1} days", "{0} in the north, {1} in the south", "solar time {0}", "{0}, {1}", "camera", "in view", "placed",
"centre of view", "spread", "points", "posts", "servers", "CDN groups", "paused", "idle", "auto-rotate", "countries",
"borders", "lines", "day/night line", "solar noon", "solar midnight", "Tropic of Cancer", "Tropic of Capricorn",
"Arctic Circle", "Antarctic Circle", "this hour, all year", "the Sun never sets", "the Sun never rises", "perihelion",
"aphelion", "not to scale", "day map", "day and night maps", "{0}× anisotropic"
];
async Task Open()
{
focusClose = true;
await ExpandedChanged.InvokeAsync(true);
}
async Task Close()
{
focusExpand = true;
await ExpandedChanged.InvokeAsync(false);
}
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && Expanded ? Close() : Task.CompletedTask;
ElementReference element;
IJSObjectReference module;
DotNetObjectReference<GlobePanel> reference;
readonly CancellationTokenSource cancellation = new();
int? sentFingerprint;
// the posts are looked at again only when the feed hands them over, never on the panel's own renders
bool postsHandedOver = true;
SunStats sun;
string error;
readonly string uid = "g" + Guid.NewGuid().ToString("N")[..8];
protected override void OnInitialized() => IsLoading = false;
2026-10-06 23:49:49 +02:00
protected override void OnParametersSet()
{
postsHandedOver = true;
// full screen, the Back button closes it
if (Expanded && overlay is null)
overlay = Overlays.Open(() => InvokeAsync(Close));
else if (!Expanded && overlay is not null)
{
overlay.Dispose();
overlay = null;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (focusClose && Expanded)
{
focusClose = false;
await closeButton.FocusAsync();
}
if (focusExpand && !Expanded)
{
focusExpand = false;
await expandButton.FocusAsync(preventScroll: true);
}
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,
Labels = InstrumentKeys.ToDictionary(key => key, key => (string)Localizer[key])
});
}
if (postsHandedOver)
{
postsHandedOver = false;
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);
// a hash, not a string of every place: a feed of thousands is looked at on every page it loads
var hash = new HashCode();
foreach (var post in located)
{
hash.Add(post.Key);
hash.Add(post.Location.Latitude);
hash.Add(post.Location.Longitude);
hash.Add(post.Location.CdnKey);
}
hash.Add(self?.Geo?.Latitude);
var fingerprint = hash.ToHashCode();
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 started, or its Sun moved to another moment (now, or a post's): the caption and the screen
/// reader's text follow. Nothing is read otherwise: the numbers are drawn in the scene (instruments.js).</summary>
[JSInvokable]
public async Task SunChanged()
{
if (module is null)
return;
try
{
sun = await module.InvokeAsync<SunStats>("sunStats", cancellation.Token);
StateHasChanged();
}
catch (OperationCanceledException)
{
}
catch (JSDisconnectedException)
{
}
}
/// <summary>The globe could not start (no WebGL, its library failed to load).</summary>
[JSInvokable]
public void GlobeFailed(string message)
{
error = message;
StateHasChanged();
}
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()
{
2026-10-06 23:49:49 +02:00
overlay?.Dispose();
await cancellation.CancelAsync();
cancellation.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);
/// <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; }
}
}