Files
decePubClient/Components/Feed/GlobePanel.razor
T
thepraandClaude Opus 5.5 620cbef219
Build / Build (push) Successful in 26s
Deploy / decepub.thepra.dev (push) Successful in 1m21s
CDN-fronted servers grouped per CDN, and servers through time
A post whose server is behind a CDN has no place of its own: the globe
groups such posts per CDN in labels on a ring above the Arctic, and
hovering one draws its arc to the CDN's group and a ring where the
server was before the CDN. The card's CDN chip names the CDN, its
domain, how PrivaPub found it (published ranges, response headers or
network), the place before the CDN and the CDN's servers week by week
(/api/privapub/v1/cdns/:domain); the author's tooltip lists the server's
changes of place and CDN week by week (/instances/:host/history). Both
are read on the first hover and kept for the session.

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

190 lines
7.1 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.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.Fps.ToString());
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048" : 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 bool AutoRotate { get; set; }
public bool Paused { get; set; }
public string Hovered { get; set; }
public int Arcs { get; set; }
public int Rings { get; set; }
public string Error { get; set; }
}
}