On a phone the only way into a post's thread was a small Expand button (and now an entry in More), while a tap on the post pinned it to a globe that is usually hidden there, outlining the card. Where the pointer can't hover, a tap on a post that isn't on its links, controls or menus and doesn't end a text selection opens its thread through the card's own thread link (main.js, data-thread on the card); cards are pinned to the globe with a mouse only. The globe's corner stats have 12px text on touch. Phone flow: a tap in a post's margin opens its thread and pins nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
407 lines
16 KiB
Plaintext
407 lines
16 KiB
Plaintext
@inherits LocalizableComponentBase
|
||
@using System.Globalization
|
||
|
||
@* 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, lit by the Sun of now or of the moment the hovered, focused or pinned post
|
||
was written. The posts' places go to the module in one call whenever they change; the module calls SunChanged when
|
||
the Sun's moment changes. The top corner shows that moment, the phase of the day and the season (its tooltip the
|
||
Sun's numbers); the bottom corner the camera and the frame rate, its tooltip the rest of the globe's numbers. The
|
||
globe is isolated: its labels carry z-indexes by depth, which must not reach the corners' tooltips. Below lg the
|
||
globe is a banner the page shows above the feed: a tap opens it full screen (Expanded, which the page lays out), and
|
||
its close button or Escape brings the feed back. *@
|
||
<div class="relative w-full h-full" tabindex="-1" @onkeydown="OnKeyDown" data-globe-state="@State">
|
||
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
|
||
|
||
@if (stats?.Sun is { } sun)
|
||
{
|
||
<div class="absolute left-2 top-2 z-10 max-w-[75%] text-[11px] pointer-coarse:text-xs leading-tight pointer-events-auto">
|
||
<NerdTip Heading="@Localizer["Sun"]" Rows="SunRows(sun)" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.Start">
|
||
<div class="flex flex-col gap-0.5 rounded-box px-2 py-1 bg-base-100/70 backdrop-blur-sm neo-raised neo-2xs">
|
||
<span class="flex items-center gap-1 font-semibold">
|
||
<i class="@SunIcon(sun)"></i>
|
||
<span>@(sun.Now ? Localizer["Now"] : Localizer["When it was written"]) · @LocalTime(sun.At)</span>
|
||
</span>
|
||
@if (sun.Phase is { } phase)
|
||
{
|
||
<span class="truncate">@sun.Place · @PhaseName(phase, sun.Morning) · @Localizer["solar time {0}", sun.SolarTime]</span>
|
||
<span>@Localizer[SeasonName(sun.Season)] · @Localizer[sun.Hemisphere == "south" ? "southern hemisphere" : "northern hemisphere"]</span>
|
||
}
|
||
else
|
||
{
|
||
<span>@Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]]</span>
|
||
}
|
||
</div>
|
||
</NerdTip>
|
||
</div>
|
||
}
|
||
|
||
@if (Expanded)
|
||
{
|
||
<button @ref="closeButton" type="button" class="btn btn-circle btn-sm absolute right-3 top-[max(0.75rem,env(safe-area-inset-top))] z-20"
|
||
aria-label="@Localizer["Close"]" title="@Localizer["Close"]" @onclick="Close" data-globe-close>
|
||
<i class="ion-md-close" aria-hidden="true"></i>
|
||
</button>
|
||
}
|
||
else
|
||
{
|
||
<button @ref="expandButton" type="button" class="absolute inset-0 z-20 lg:hidden cursor-zoom-in" aria-label="@Localizer["Open the globe full screen"]"
|
||
title="@Localizer["Open the globe full screen"]" @onclick="Open" data-globe-expand>
|
||
<i class="ion-md-expand absolute right-3 bottom-3 text-lg rounded-full bg-base-100/70 p-1" aria-hidden="true"></i>
|
||
</button>
|
||
}
|
||
|
||
<div class="absolute left-2 bottom-2 z-10 flex items-center gap-2 text-[10px] pointer-coarse:text-xs 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; } = [];
|
||
/// <summary>Open full screen (a phone's banner tapped); the page lays the globe out over everything.</summary>
|
||
[Parameter] public bool Expanded { get; set; }
|
||
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
|
||
|
||
ElementReference closeButton;
|
||
ElementReference expandButton;
|
||
bool focusClose;
|
||
bool focusExpand;
|
||
|
||
string State => stats is null ? "loading" : stats.Error is { Length: > 0 } ? "error" : "live";
|
||
|
||
async Task Open()
|
||
{
|
||
focusClose = true;
|
||
await ExpandedChanged.InvokeAsync(true);
|
||
}
|
||
|
||
async Task Close()
|
||
{
|
||
focusExpand = true;
|
||
await ExpandedChanged.InvokeAsync(false);
|
||
}
|
||
|
||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && Expanded ? Close() : Task.CompletedTask;
|
||
|
||
ElementReference element;
|
||
IJSObjectReference module;
|
||
DotNetObjectReference<GlobePanel> reference;
|
||
PeriodicTimer statsTimer;
|
||
readonly CancellationTokenSource cancellation = new();
|
||
int? sentFingerprint;
|
||
// the posts are looked at again only when the feed hands them over, never on the stats overlay's own renders
|
||
bool postsHandedOver = true;
|
||
GlobeStats stats;
|
||
|
||
protected override void OnInitialized() => IsLoading = false;
|
||
|
||
protected override void OnParametersSet() => postsHandedOver = true;
|
||
|
||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||
{
|
||
if (focusClose && Expanded)
|
||
{
|
||
focusClose = false;
|
||
await closeButton.FocusAsync();
|
||
}
|
||
if (focusExpand && !Expanded)
|
||
{
|
||
focusExpand = false;
|
||
await expandButton.FocusAsync(preventScroll: true);
|
||
}
|
||
await base.OnAfterRenderAsync(firstRender);
|
||
try
|
||
{
|
||
if (firstRender)
|
||
{
|
||
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
|
||
reference = DotNetObjectReference.Create(this);
|
||
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference,
|
||
new { Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName });
|
||
statsTimer = new(TimeSpan.FromSeconds(1));
|
||
_ = ReadStats(cancellation.Token);
|
||
}
|
||
if (postsHandedOver)
|
||
{
|
||
postsHandedOver = false;
|
||
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);
|
||
// a hash, not a string of every place: a feed of thousands is looked at on every page it loads
|
||
var hash = new HashCode();
|
||
foreach (var post in located)
|
||
{
|
||
hash.Add(post.Key);
|
||
hash.Add(post.Location.Latitude);
|
||
hash.Add(post.Location.Longitude);
|
||
hash.Add(post.Location.CdnKey);
|
||
}
|
||
hash.Add(self?.Geo?.Latitude);
|
||
var fingerprint = hash.ToHashCode();
|
||
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;
|
||
}
|
||
|
||
/// <summary>The globe's Sun moved to another moment (now, or a post's): the overlay follows at once.</summary>
|
||
[JSInvokable]
|
||
public async Task SunChanged()
|
||
{
|
||
if (module is null)
|
||
return;
|
||
|
||
try
|
||
{
|
||
stats = await module.InvokeAsync<GlobeStats>("stats", cancellation.Token);
|
||
StateHasChanged();
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
}
|
||
catch (JSDisconnectedException)
|
||
{
|
||
}
|
||
}
|
||
|
||
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(Localizer["countries"], stats.Countries > 0 ? $"{stats.Countries} ({stats.CountryNames} {Localizer["named at this zoom"]})" : null);
|
||
yield return NerdRow.Of(Localizer["borders"], stats.Borders > 0 ? $"{stats.Borders} {Localizer["lines"]}" : null);
|
||
yield return NerdRow.Of("WebGL", stats.Renderer);
|
||
yield return NerdRow.Of(Localizer["error"], stats.Error);
|
||
}
|
||
|
||
IEnumerable<NerdRow> SunRows(SunStats sun)
|
||
{
|
||
yield return NerdRow.Of(Localizer["moment"], DateTime.Parse(sun.At, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal));
|
||
yield return NerdRow.Of(Localizer["subsolar point"], $"{Fixed(sun.Latitude)}, {Fixed(sun.Longitude)}");
|
||
yield return NerdRow.Of(Localizer["declination"], $"{Fixed(sun.Latitude)}°");
|
||
yield return NerdRow.Of(Localizer["axial tilt"], $"{Fixed(sun.Obliquity)}°");
|
||
yield return NerdRow.Of(Localizer["ecliptic longitude"], $"{Fixed(sun.EclipticLongitude)}°");
|
||
yield return NerdRow.Of(Localizer["Earth–Sun distance"],
|
||
$"{sun.Distance.ToString("0.00000", CultureInfo.InvariantCulture)} AU ({sun.Distance * 149_597_870.7:N0} km)");
|
||
yield return NerdRow.Of(Localizer["irradiance"],
|
||
$"{sun.Irradiance * 1361:0} W/m² ({Math.Round((sun.Irradiance - 1) * 100, 1).ToString("+0.0;-0.0;0.0", CultureInfo.InvariantCulture)}%)");
|
||
yield return NerdRow.Of(Localizer["Place"], sun.Place is { } place ? $"{place} ({Fixed(sun.PlaceLatitude)}, {Fixed(sun.PlaceLongitude)})" : null);
|
||
yield return NerdRow.Of(Localizer["elevation"], sun.Elevation is { } elevation ? $"{Fixed(elevation)}°" : null);
|
||
yield return NerdRow.Of(Localizer["day length"], sun.DayLength is { } hours ? NerdRow.Duration(TimeSpan.FromHours(hours)) : null);
|
||
yield return NerdRow.Of(Localizer["seasons"], $"{Localizer["{0} in the north, {1} in the south", Localizer[SeasonName(sun.SeasonNorth)], Localizer[SeasonName(sun.SeasonSouth)]]}");
|
||
yield return NerdRow.Of(Localizer["next"], sun.NextEvent is { } next ? Localizer["{0} in {1} days", Localizer[EventName(next)], sun.NextEventDays] : null);
|
||
}
|
||
|
||
static string Fixed(double? value) => value?.ToString("0.##", CultureInfo.InvariantCulture) ?? "–";
|
||
|
||
/// <summary>The Sun's moment in the reader's time zone.</summary>
|
||
static string LocalTime(string at) =>
|
||
DateTimeOffset.TryParse(at, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var time)
|
||
? time.ToLocalTime().ToString(SUtility.dateTimeFormatExt, CultureInfo.InvariantCulture)
|
||
: at;
|
||
|
||
static string SunIcon(SunStats sun) => sun.Phase switch
|
||
{
|
||
"day" => "ion-md-sunny",
|
||
"golden" => "ion-md-partly-sunny",
|
||
"civil" or "nautical" or "astronomical" => "ion-md-cloudy-night",
|
||
"night" => "ion-md-moon",
|
||
_ => "ion-md-time"
|
||
};
|
||
|
||
string PhaseName(string phase, bool? morning) => phase switch
|
||
{
|
||
"day" => Localizer["daylight"],
|
||
"night" => Localizer["night"],
|
||
_ => Localizer["{0}, {1}", Localizer[phase switch
|
||
{
|
||
"golden" => "golden hour",
|
||
"civil" => "civil twilight",
|
||
"nautical" => "nautical twilight",
|
||
_ => "astronomical twilight"
|
||
}], Localizer[morning == true ? "morning" : "evening"]]
|
||
};
|
||
|
||
static string SeasonName(string season) => season switch
|
||
{
|
||
"spring" => "spring",
|
||
"summer" => "summer",
|
||
"autumn" => "autumn",
|
||
_ => "winter"
|
||
};
|
||
|
||
static string EventName(string next) => next switch
|
||
{
|
||
"march-equinox" => "March equinox",
|
||
"june-solstice" => "June solstice",
|
||
"september-equinox" => "September equinox",
|
||
_ => "December solstice"
|
||
};
|
||
|
||
public override async ValueTask DisposeAsync()
|
||
{
|
||
await cancellation.CancelAsync();
|
||
cancellation.Dispose();
|
||
statsTimer?.Dispose();
|
||
reference?.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 int Countries { get; set; }
|
||
public int CountryNames { get; set; }
|
||
public int Borders { get; set; }
|
||
public SunStats Sun { get; set; }
|
||
public string Error { get; set; }
|
||
}
|
||
|
||
/// <summary>The Sun of the globe's moment (wwwroot/js/sun.js); the place fields are null without a focused located post.</summary>
|
||
sealed class SunStats
|
||
{
|
||
public string At { get; set; }
|
||
public bool Now { get; set; }
|
||
public double Latitude { get; set; }
|
||
public double Longitude { get; set; }
|
||
public double Distance { get; set; }
|
||
public double Irradiance { get; set; }
|
||
public double EclipticLongitude { get; set; }
|
||
public double Obliquity { get; set; }
|
||
public string Place { get; set; }
|
||
public double? PlaceLatitude { get; set; }
|
||
public double? PlaceLongitude { get; set; }
|
||
public double? Elevation { get; set; }
|
||
public string Phase { get; set; }
|
||
public bool? Morning { get; set; }
|
||
public string SolarTime { get; set; }
|
||
public double? DayLength { get; set; }
|
||
public string Season { get; set; }
|
||
public string Hemisphere { get; set; }
|
||
public string SeasonNorth { get; set; }
|
||
public string SeasonSouth { get; set; }
|
||
public string NextEvent { get; set; }
|
||
public int? NextEventDays { get; set; }
|
||
}
|
||
}
|