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
This commit is contained in:
1 parent
e88f4afc45
commit
e94e32d198
9 files changed
+1516
-181
No files matched your search
@@ -3,36 +3,41 @@
|
||||
|
||||
@* 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. 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. *@
|
||||
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"></div>
|
||||
<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>
|
||||
|
||||
@if (stats?.Sun is { } sun)
|
||||
@* the moment the Sun is shown at: now, or the focused post's *@
|
||||
@if (sun is not null)
|
||||
{
|
||||
<div class="absolute left-2 top-2 z-10 max-w-[75%] text-[11px] pointer-coarse:text-xs 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>
|
||||
<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)
|
||||
@@ -50,25 +55,6 @@
|
||||
</button>
|
||||
}
|
||||
|
||||
<div class="absolute left-2 bottom-2 z-10 flex items-center gap-2 text-[10px] pointer-coarse:text-xs 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 {
|
||||
@@ -90,7 +76,20 @@
|
||||
bool focusClose;
|
||||
bool focusExpand;
|
||||
|
||||
string State => stats is null ? "loading" : stats.Error is { Length: > 0 } ? "error" : "live";
|
||||
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()
|
||||
{
|
||||
@@ -109,12 +108,13 @@
|
||||
ElementReference element;
|
||||
IJSObjectReference module;
|
||||
DotNetObjectReference<GlobePanel> reference;
|
||||
PeriodicTimer statsTimer;
|
||||
readonly CancellationTokenSource cancellation = new();
|
||||
int? sentFingerprint;
|
||||
// the posts are looked at again only when the feed hands them over, never on the stats overlay's own renders
|
||||
// the posts are looked at again only when the feed hands them over, never on the panel's own renders
|
||||
bool postsHandedOver = true;
|
||||
GlobeStats stats;
|
||||
SunStats sun;
|
||||
string error;
|
||||
readonly string uid = "g" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
|
||||
@@ -150,10 +150,11 @@
|
||||
{
|
||||
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 module.InvokeVoidAsync("create", element, ScrollContainerId, reference, new
|
||||
{
|
||||
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||
Labels = InstrumentKeys.ToDictionary(key => key, key => (string)Localizer[key])
|
||||
});
|
||||
}
|
||||
if (postsHandedOver)
|
||||
{
|
||||
@@ -213,7 +214,8 @@
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>The globe's Sun moved to another moment (now, or a post's): the overlay follows at once.</summary>
|
||||
/// <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()
|
||||
{
|
||||
@@ -222,7 +224,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
stats = await module.InvokeAsync<GlobeStats>("stats", cancellation.Token);
|
||||
sun = await module.InvokeAsync<SunStats>("sunStats", cancellation.Token);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -233,42 +235,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
async Task ReadStats(CancellationToken cancellationToken)
|
||||
/// <summary>The globe could not start (no WebGL, its library failed to load).</summary>
|
||||
[JSInvokable]
|
||||
public void GlobeFailed(string message)
|
||||
{
|
||||
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);
|
||||
error = message;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
IEnumerable<NerdRow> SunRows(SunStats sun)
|
||||
@@ -340,7 +312,6 @@
|
||||
overlay?.Dispose();
|
||||
await cancellation.CancelAsync();
|
||||
cancellation.Dispose();
|
||||
statsTimer?.Dispose();
|
||||
reference?.Dispose();
|
||||
try
|
||||
{
|
||||
@@ -361,37 +332,6 @@
|
||||
|
||||
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
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user