Files
decePubClient/Components/Feed/GlobePanel.razor
T
thepraandClaude Opus 5.5 e94e32d198 The globe's nerd stats are instruments in the scene
Owner decision 2026-10-06: the globe tells its numbers where they belong instead of in a table. instruments.js adds
one three.js group to globe.gl's scene and a DOM overlay of chips:
- the Sun: a ray and a bead over the subsolar point (with the irradiance), the day/night line, the golden hour and
  the three twilights, the declination, the noon and midnight meridian, and the analemma at this hour, its stretch to
  the next equinox or solstice brighter;
- a focused post's place: its horizon, its Sun's line and height, and its day lit on its latitude, with one chip for
  the place, the phase, the solar time, the elevation and the day's length;
- the tropics and polar circles (the axial tilt), the CDN ring, the view's centre and spread, and rings around the
  posts in view;
- a small orrery in the top left corner, re-anchored to the camera before each render, with the Earth–Sun distance,
  the seasons, the next equinox or solstice and the next perihelion or aphelion stacked under it;
- the frame rate as a ring around the globe; the rest (points, textures, countries, renderer) as a legend.

The chips are placed after each frame globe.gl draws, clear of each other, the moment's caption, the orrery and the
legend, and slide back inside the canvas at its edge; country names keep clear of them. Geometry changes only inside
a wake globe.js already made, so an idle globe still draws nothing. three.js's classes come from globe.gl's own
objects, looked for before each render until its globe is in the scene. A phone's banner draws the day/night line
and the subsolar point; full screen draws everything, and a globe built in the banner now moves its camera back when
opened full screen.

GlobePanel loses both tooltips and the one-second poll that read every post into a table; the Sun's rows stay as
screen-reader text, and a failed start says so. Seven keys only the tooltips used are gone.

GlobeTests (desktop with a located post written from Milano and pinned, in both themes and turned to the subsolar
point; a phone's banner, full screen and back; an idle globe drawing no frame) pass. Under SwiftShader the instruments
cost 1–3 ms of a ~50 ms frame and under 0.1 ms of script; FeedPerfTests' heaps are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 02:15:06 +02:00

362 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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.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()
{
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; }
}
}