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
190 lines
7.1 KiB
Plaintext
190 lines
7.1 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.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; }
|
||
}
|
||
}
|