@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. *@
@if (stats is { Error: null } live)
{
@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @(live.Idle ? Localizer["idle"] : $"{live.Fps} fps")
}
else if (stats?.Error is { Length: > 0 })
{
@Localizer["The globe could not start."]
}
else
{
@Localizer["loading"]…
}
@code {
[Inject] IJSRuntime JS { get; set; }
[Inject] InstanceService InstanceService { get; set; }
/// The id of the element that scrolls the feed's cards.
[Parameter, EditorRequired] public string ScrollContainerId { get; set; }
[Parameter] public List 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("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)}");
}
}
/// Sends the located posts when they changed since the last call.
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("stats", cancellationToken);
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
catch (JSDisconnectedException)
{
}
}
IEnumerable 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; }
}
}