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
192 lines
7.3 KiB
Plaintext
192 lines
7.3 KiB
Plaintext
@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; }
|
||
}
|
||
}
|