Build / Build (push) Successful in 33s
- Every number shown on a page goes through NumbersExtensions: counts, percentages, the nerd readouts and the values written into a style. - A failure the client builds itself (offline, timeout, retries run out, an answer it can't read) is a 400 that names its kind; only a server's own 5XX passes through. - Login, sign-up, recovery and password confirmations leave autocomplete to the browser, so it can offer the saved login. - The Daisy components start with empty lists and attributes, and a decorative image still renders alt="". - Arasaka.HouseRules.Analyzers checks all of this in every build, together with PrivaPub.ClientModels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4YtEJrgxG12Pc39Fai8Bd
362 lines
14 KiB
Plaintext
362 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. 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; }
|
||
|
||
[Inject] OverlayService Overlays { get; set; }
|
||
|
||
ElementReference closeButton;
|
||
ElementReference expandButton;
|
||
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;
|
||
|
||
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.ToFixed(5)} AU ({(sun.Distance * 149_597_870.7).ToDefaultCount()} km)");
|
||
yield return NerdRow.Of(Localizer["irradiance"],
|
||
$"{(sun.Irradiance * 1361).ToDefaultCount()} W/m² ({Math.Round((sun.Irradiance - 1) * 100, 1).ToSignedPercent()})");
|
||
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()
|
||
{
|
||
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; }
|
||
}
|
||
}
|