Files
decePubClient/Components/Feed/GlobePanel.razor
T
thepraandClaude Opus 5.5 69d0a641fe The globe draws the poles as they are, and nothing while idle
The globe is a UV sphere: towards the poles its triangles narrow to
slivers, and texture coordinates interpolated across them pinched the
map into a star around each pole. Each pixel now computes its own
coordinates from its exact point on the sphere, with seam-safe mipmap
gradients; the map wraps horizontally and gets 8x anisotropic
filtering.

That showed a flaw in the day texture: NASA's mosaic has a lighter
strip at its east edge near the poles, a line from each pole once drawn
exactly. The day texture is now built from NASA's original Blue Marble
(December 2004) by tools/globe/prepare-day-texture.py, which blends
that strip into the map where both sides of the seam agree; same
4096x2048 size, provenance and hashes in SOURCES.md.

globe.gl redrew 60 times a second even when nothing moved; it now draws
only during camera flights, hover pulses, auto-rotation and the
reader's drags, so an idle globe costs no GPU time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 17:46:44 +02:00

192 lines
7.3 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
@* 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. The posts' places go to the module in one call whenever they change; the
corner shows the camera and the frame rate, its tooltip the rest of the globe's numbers. *@
<div class="relative w-full h-full">
<div @ref="element" class="absolute inset-0" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
<div class="absolute left-2 bottom-2 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;
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");
await module.InvokeVoidAsync("create", element, ScrollContainerId);
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;
}
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("WebGL", stats.Renderer);
yield return NerdRow.Of(Localizer["error"], stats.Error);
}
static string Fixed(double? value) => value?.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture) ?? "–";
public override async ValueTask DisposeAsync()
{
await cancellation.CancelAsync();
cancellation.Dispose();
statsTimer?.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 string Error { get; set; }
}
}