@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.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}")) + $"#{self?.Geo?.Latitude}"; if (fingerprint == sentFingerprint) return; sentFingerprint = fingerprint; var points = located.Select(p => { var server = InstanceService.Known(p.Host)?.Geo; return new GlobePoint(p.Key, p.Location.Latitude, p.Location.Longitude, p.Location.Source.ToString().ToLowerInvariant(), p.Location.Label, p.Host, server?.Latitude, server?.Longitude, p.Located ? p.RangeKm : null); }).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["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); 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 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; } } }