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
This commit is contained in:
1 parent
5ec70b3357
commit
620cbef219
12 files changed
+386
-41
No files matched your search
@@ -189,19 +189,24 @@ supplies:
|
|||||||
`PrivaPub.ClientModels`.
|
`PrivaPub.ClientModels`.
|
||||||
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
|
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
|
||||||
its event's venue, the reader for a located post, its author's server (to 0.1°, or a point in its country from
|
its event's venue, the reader for a located post, its author's server (to 0.1°, or a point in its country from
|
||||||
`Helpers/CountryPoints.cs` when DB-IP located it only to a country), or nowhere (a CDN-fronted server).
|
`Helpers/CountryPoints.cs` when DB-IP located it only to a country), or, for a CDN-fronted server, its CDN's group
|
||||||
Server descriptions are cached a day in localStorage (`ServerInfos`). PrivaPub's own host is the signed-in persona's
|
(`LocationSource.Cdn`: no point, a `CdnKey` and the place `Before` the CDN). Server descriptions are cached a day in
|
||||||
account host, since the API may be reached under another name.
|
localStorage (`ServerInfos`); a server's weekly history (`/instances/:host/history`) and a CDN's page (`/cdns/:key`)
|
||||||
|
are read on the first hover and kept for the session. PrivaPub's own host is the signed-in persona's account host,
|
||||||
|
since the API may be reached under another name.
|
||||||
- **The feed page** (`Pages/Index.razor`): `FeedTabs`, `Content` (the card, which does its own actions), `Composer`,
|
- **The feed page** (`Pages/Index.razor`): `FeedTabs`, `Content` (the card, which does its own actions), `Composer`,
|
||||||
`GlobePanel`. Older pages load as the end comes near (`wwwroot/js/feed.js`); newer ones are polled every 45 s while
|
`GlobePanel`. Older pages load as the end comes near (`wwwroot/js/feed.js`); newer ones are polled every 45 s while
|
||||||
the page is visible (PrivaPub has no streaming) and wait behind a "new" button.
|
the page is visible (PrivaPub has no streaming) and wait behind a "new" button.
|
||||||
- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in
|
- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in
|
||||||
`wwwroot/vendor/globe/` with its textures (`SOURCES.md` has versions, licences and hashes; the service worker never
|
`wwwroot/vendor/globe/` with its textures (`SOURCES.md` has versions, licences and hashes; the service worker never
|
||||||
precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an arc to a hovered
|
precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an arc to a hovered
|
||||||
post, follows the theme (day/night texture), honours `prefers-reduced-motion` and pauses while hidden. The posts'
|
post, follows the theme (day/night texture), honours `prefers-reduced-motion` and pauses while hidden. CDN-fronted
|
||||||
|
posts are labels per CDN on a ring at 68°N, never a place; hovering one draws its arc there and a ring where the server
|
||||||
|
was before the CDN. The posts'
|
||||||
places reach it in one call per feed change; cards are matched by `data-post-id`.
|
places reach it in one call per feed change; cards are matched by `data-post-id`.
|
||||||
- **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on
|
- **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on
|
||||||
hover), its author and server, its place, media and counts; the feed's paging and last request; the globe's camera,
|
hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how
|
||||||
|
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last request; the globe's camera,
|
||||||
points and frame rate.
|
points and frame rate.
|
||||||
|
|
||||||
**Localization (`Services/CoalescingStringLocalizer.cs`).**
|
**Localization (`Services/CoalescingStringLocalizer.cs`).**
|
||||||
|
|||||||
+89
-14
@@ -3,12 +3,13 @@
|
|||||||
@* One feed item: a post (a boost shows the original and its booster), a notification, or a conversation's last post.
|
@* One feed item: a post (a boost shows the original and its booster), a notification, or a conversation's last post.
|
||||||
The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page
|
The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page
|
||||||
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
|
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
|
||||||
quoted post (Compact) has none, since it is not an item of the feed. Located
|
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
|
||||||
posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on the time, the author, the place,
|
its CDN's group (data-cdn). Located posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on
|
||||||
the media and the counts. *@
|
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
|
||||||
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
|
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
|
||||||
data-post-id="@(Compact ? null : Post.Key)" data-lat="@(Compact ? null : Coordinate(Post.Location?.Latitude))"
|
data-post-id="@(Compact ? null : Post.Key)" data-lat="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Latitude))"
|
||||||
data-lng="@(Compact ? null : Coordinate(Post.Location?.Longitude))" data-src="@(Compact ? null : Post.Location?.Source.ToString().ToLowerInvariant())"
|
data-lng="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Longitude))"
|
||||||
|
data-src="@(Compact ? null : Post.Location?.Source.ToString().ToLowerInvariant())" data-cdn="@(Compact ? null : Post.Location?.CdnKey)"
|
||||||
data-label="@(Compact ? null : Post.Location?.Label)" data-host="@(Compact ? null : Post.Host)"
|
data-label="@(Compact ? null : Post.Location?.Label)" data-host="@(Compact ? null : Post.Host)"
|
||||||
data-range="@(Post.Located && !Compact ? Coordinate(Post.RangeKm) : null)">
|
data-range="@(Post.Located && !Compact ? Coordinate(Post.RangeKm) : null)">
|
||||||
|
|
||||||
@@ -53,14 +54,24 @@
|
|||||||
@if (Status is { } status)
|
@if (Status is { } status)
|
||||||
{
|
{
|
||||||
<header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 min-w-0 justify-between text-xs md:text-sm">
|
<header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 min-w-0 justify-between text-xs md:text-sm">
|
||||||
<NerdTip Heading="@Localizer["Author"]" Rows="AuthorRows()" Align="DTooltipAlign.Start" Class="min-w-0 flex-1">
|
<NerdTip Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" OnOpen="ReadServer" Align="DTooltipAlign.Start" Class="min-w-0 flex-1">
|
||||||
<span class="inline-flex gap-2 min-w-0 max-w-full">
|
<span class="inline-flex gap-2 min-w-0 max-w-full">
|
||||||
<b class="shrink truncate" title="@status.Account?.Name">@status.Account?.Name</b>
|
<b class="shrink truncate" title="@status.Account?.Name">@status.Account?.Name</b>
|
||||||
<span class="flex-1 min-w-6 opacity-50 truncate self-center text-xs">@("@" + status.Account?.Acct)</span>
|
<span class="flex-1 min-w-6 opacity-50 truncate self-center text-xs">@("@" + status.Account?.Acct)</span>
|
||||||
</span>
|
</span>
|
||||||
</NerdTip>
|
</NerdTip>
|
||||||
<span class="flex-none inline-flex gap-2 items-center">
|
<span class="flex-none inline-flex gap-2 items-center">
|
||||||
@if (Post.Location is { } location)
|
@if (Post.Location is { Source: LocationSource.Cdn } fronted)
|
||||||
|
{
|
||||||
|
<NerdTip Heading="@Localizer["The server is behind {0}, so its place is unknown.", fronted.Label]" Rows="CdnRows()" Pending="isReadingServer"
|
||||||
|
OnOpen="ReadServer" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||||
|
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
|
||||||
|
<i class="ion-md-cloud" aria-hidden="true"></i>
|
||||||
|
<span class="truncate">@fronted.Label</span>
|
||||||
|
</span>
|
||||||
|
</NerdTip>
|
||||||
|
}
|
||||||
|
else if (Post.Location is { } location)
|
||||||
{
|
{
|
||||||
<NerdTip Heading="@Localizer["Place"]" Rows="LocationRows()" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
<NerdTip Heading="@Localizer["Place"]" Rows="LocationRows()" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||||
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
|
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
|
||||||
@@ -69,13 +80,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</NerdTip>
|
</NerdTip>
|
||||||
}
|
}
|
||||||
else if (ServerGeo?.Cdn is { Length: > 0 } cdn)
|
|
||||||
{
|
|
||||||
<span class="badge badge-ghost badge-sm gap-1" title="@Localizer["The server is behind {0}, so its place is unknown.", cdn]">
|
|
||||||
<i class="ion-md-cloud" aria-hidden="true"></i>
|
|
||||||
<span>@cdn</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<NerdTip Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" OnOpen="ReadProvenance"
|
<NerdTip Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" OnOpen="ReadProvenance"
|
||||||
Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||||
<time datetime="@NerdRow.Time(status.CreatedAt)" class="opacity-80">@status.CreatedAt.GetPassedTime(Localizer)</time>
|
<time datetime="@NerdRow.Time(status.CreatedAt)" class="opacity-80">@status.CreatedAt.GetPassedTime(Localizer)</time>
|
||||||
@@ -235,6 +239,7 @@
|
|||||||
bool isOptionsOpen;
|
bool isOptionsOpen;
|
||||||
bool isSpoilerOpen;
|
bool isSpoilerOpen;
|
||||||
bool isReadingProvenance;
|
bool isReadingProvenance;
|
||||||
|
bool isReadingServer;
|
||||||
readonly HashSet<string> revealedMedia = [];
|
readonly HashSet<string> revealedMedia = [];
|
||||||
|
|
||||||
Status Status => Post.Status;
|
Status Status => Post.Status;
|
||||||
@@ -408,6 +413,18 @@
|
|||||||
isReadingProvenance = false;
|
isReadingProvenance = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The author's server through time, and its CDN's page when it has one: read on the first hover.
|
||||||
|
async Task ReadServer()
|
||||||
|
{
|
||||||
|
if (Post.Host is null)
|
||||||
|
return;
|
||||||
|
isReadingServer = true;
|
||||||
|
await InstanceService.History(Post.Host);
|
||||||
|
if (Post.Location?.CdnKey is { } cdn)
|
||||||
|
await InstanceService.Cdn(cdn);
|
||||||
|
isReadingServer = false;
|
||||||
|
}
|
||||||
|
|
||||||
static bool CanBoost(Status status) => status.Visibility is "public" or "unlisted";
|
static bool CanBoost(Status status) => status.Visibility is "public" or "unlisted";
|
||||||
|
|
||||||
static string Coordinate(double? value) => value?.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
|
static string Coordinate(double? value) => value?.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
@@ -548,6 +565,64 @@
|
|||||||
yield return NerdRow.Of(Localizer["last delivery"], server.LastSuccessAt);
|
yield return NerdRow.Of(Localizer["last delivery"], server.LastSuccessAt);
|
||||||
yield return NerdRow.Of(Localizer["failures in a row"], server.ConsecutiveFailures > 0 ? server.ConsecutiveFailures : null);
|
yield return NerdRow.Of(Localizer["failures in a row"], server.ConsecutiveFailures > 0 ? server.ConsecutiveFailures : null);
|
||||||
yield return NerdRow.Of(Localizer["described"], server.DescribedAt);
|
yield return NerdRow.Of(Localizer["described"], server.DescribedAt);
|
||||||
|
foreach (var row in HistoryRows())
|
||||||
|
yield return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server's weeks, newest first: how many are known, and every change of place or CDN.
|
||||||
|
IEnumerable<NerdRow> HistoryRows()
|
||||||
|
{
|
||||||
|
var history = InstanceService.KnownHistory(Post.Host);
|
||||||
|
if (history is not { Count: > 0 })
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
yield return NerdRow.Of(Localizer["weeks known"], $"{history.Count} ({history[^1].Week} – {history[0].Week})");
|
||||||
|
var changes = 0;
|
||||||
|
for (var i = history.Count - 1; i >= 0 && changes < 6; i--)
|
||||||
|
{
|
||||||
|
var place = WeekPlace(history[i]);
|
||||||
|
if (i < history.Count - 1 && place == WeekPlace(history[i + 1]))
|
||||||
|
continue;
|
||||||
|
yield return new(history[i].Week, place);
|
||||||
|
changes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string WeekPlace(InstanceWeek week) => week.Geo switch
|
||||||
|
{
|
||||||
|
{ Cdn: { Length: > 0 } cdn } => "☁ " + cdn,
|
||||||
|
{ } geo => JoinPlace(geo.City, InstanceService.CountryName(geo.Country)) ?? Localizer["unknown"],
|
||||||
|
_ => week.Reachable ? Localizer["unknown"] : Localizer["unreachable"]
|
||||||
|
};
|
||||||
|
|
||||||
|
static string JoinPlace(params string[] parts) => parts.Where(p => !string.IsNullOrWhiteSpace(p)) is var known && known.Any() ? string.Join(", ", known) : null;
|
||||||
|
|
||||||
|
IEnumerable<NerdRow> CdnRows()
|
||||||
|
{
|
||||||
|
var location = Post.Location;
|
||||||
|
var geo = ServerGeo;
|
||||||
|
yield return NerdRow.Of("CDN", location.Label);
|
||||||
|
yield return NerdRow.Of(Localizer["domain"], location.CdnKey);
|
||||||
|
yield return NerdRow.Of(Localizer["found by"], geo?.CdnSource switch
|
||||||
|
{
|
||||||
|
"ranges" => Localizer["the CDN's published addresses"],
|
||||||
|
"headers" => Localizer["the CDN's marks in the server's answers"],
|
||||||
|
"asn" => Localizer["a network that carries only the CDN"],
|
||||||
|
_ => geo?.CdnSource
|
||||||
|
});
|
||||||
|
if (location.Before is { } before)
|
||||||
|
yield return NerdRow.Of(Localizer["before the CDN"],
|
||||||
|
$"{JoinPlace(before.City, InstanceService.CountryName(before.Country)) ?? Localizer["unknown"]} ({before.Week})");
|
||||||
|
if (InstanceService.KnownCdn(location.CdnKey) is not { } cdn)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
yield return NerdRow.Of(Localizer["servers behind it"], cdn.Servers);
|
||||||
|
if (cdn.Ranges > 0)
|
||||||
|
yield return NerdRow.Of(Localizer["published ranges"], $"{cdn.Ranges} ({NerdRow.Time(cdn.RangesUpdatedAt ?? default)})");
|
||||||
|
var weeks = cdn.Weeks.Take(6).Reverse().ToList();
|
||||||
|
if (weeks.Count > 0)
|
||||||
|
yield return NerdRow.Of(Localizer["weekly"], string.Join(" · ", weeks.Select(w => $"{w.Week[^3..]} {w.Servers}" +
|
||||||
|
(w.Joined.Count > 0 ? $" +{w.Joined.Count}" : null) + (w.Left.Count > 0 ? $" −{w.Left.Count}" : null))));
|
||||||
}
|
}
|
||||||
|
|
||||||
IEnumerable<NerdRow> LocationRows()
|
IEnumerable<NerdRow> LocationRows()
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
var located = Posts.Where(p => p.Location is not null).ToList();
|
var located = Posts.Where(p => p.Location is not null).ToList();
|
||||||
var self = InstanceService.Known(InstanceService.SelfHost);
|
var self = InstanceService.Known(InstanceService.SelfHost);
|
||||||
var fingerprint = string.Join('|', located.Select(p => $"{p.Key}:{p.Location.Latitude}:{p.Location.Longitude}")) + $"#{self?.Geo?.Latitude}";
|
var fingerprint = string.Join('|', located.Select(p => $"{p.Key}:{p.Location.Latitude}:{p.Location.Longitude}:{p.Location.CdnKey}")) + $"#{self?.Geo?.Latitude}";
|
||||||
if (fingerprint == sentFingerprint)
|
if (fingerprint == sentFingerprint)
|
||||||
return;
|
return;
|
||||||
sentFingerprint = fingerprint;
|
sentFingerprint = fingerprint;
|
||||||
@@ -83,8 +83,10 @@
|
|||||||
var points = located.Select(p =>
|
var points = located.Select(p =>
|
||||||
{
|
{
|
||||||
var server = InstanceService.Known(p.Host)?.Geo;
|
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,
|
var before = p.Location.Before;
|
||||||
server?.Latitude, server?.Longitude, p.Located ? p.RangeKm : null);
|
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();
|
}).ToList();
|
||||||
await module.InvokeVoidAsync("setPosts", points, SelfPoint(self));
|
await module.InvokeVoidAsync("setPosts", points, SelfPoint(self));
|
||||||
}
|
}
|
||||||
@@ -125,6 +127,7 @@
|
|||||||
yield return NerdRow.Of(Localizer["centre of view"], stats.CentroidLatitude is { } latitude ? $"{Fixed(latitude)}, {Fixed(stats.CentroidLongitude)}" : null);
|
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["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["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(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("fps", stats.Paused ? Localizer["paused"] : stats.Fps.ToString());
|
||||||
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
|
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
|
||||||
@@ -154,7 +157,8 @@
|
|||||||
await base.DisposeAsync();
|
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 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 record GlobeSelf(double Lat, double Lng, string Label, string Host);
|
||||||
|
|
||||||
@@ -171,6 +175,7 @@
|
|||||||
public int Points { get; set; }
|
public int Points { get; set; }
|
||||||
public int Posts { get; set; }
|
public int Posts { get; set; }
|
||||||
public int Servers { get; set; }
|
public int Servers { get; set; }
|
||||||
|
public int CdnGroups { get; set; }
|
||||||
public int Fps { get; set; }
|
public int Fps { get; set; }
|
||||||
public string Renderer { get; set; }
|
public string Renderer { get; set; }
|
||||||
public string Texture { get; set; }
|
public string Texture { get; set; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
using decePubClient.Models.Mastodon;
|
using decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
@@ -24,6 +24,9 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(List<Conversation>))]
|
[JsonSerializable(typeof(List<Conversation>))]
|
||||||
[JsonSerializable(typeof(InstanceDescription))]
|
[JsonSerializable(typeof(InstanceDescription))]
|
||||||
[JsonSerializable(typeof(List<InstanceDescription>))]
|
[JsonSerializable(typeof(List<InstanceDescription>))]
|
||||||
|
[JsonSerializable(typeof(List<InstanceWeek>))]
|
||||||
|
[JsonSerializable(typeof(List<CdnSummary>))]
|
||||||
|
[JsonSerializable(typeof(CdnDetail))]
|
||||||
[JsonSerializable(typeof(Provenance))]
|
[JsonSerializable(typeof(Provenance))]
|
||||||
[JsonSerializable(typeof(TokenResponse))]
|
[JsonSerializable(typeof(TokenResponse))]
|
||||||
[JsonSerializable(typeof(MastodonError))]
|
[JsonSerializable(typeof(MastodonError))]
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace decePubClient.Models;
|
namespace decePubClient.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Every PrivaPub route the client calls, relative to AppConfiguration.ApiBaseAddress (b2b.next's naming: the HTTP
|
/// Every PrivaPub route the client calls, relative to AppConfiguration.ApiBaseAddress (b2b.next's naming: the HTTP
|
||||||
@@ -59,6 +59,10 @@ public static class APIs
|
|||||||
public static class PrivaPub
|
public static class PrivaPub
|
||||||
{
|
{
|
||||||
public const string GET_Instances = "api/privapub/v1/instances";
|
public const string GET_Instances = "api/privapub/v1/instances";
|
||||||
|
public const string GET_Cdns = "api/privapub/v1/cdns";
|
||||||
|
|
||||||
|
public static string GET_M_InstanceHistory(string host) => $"api/privapub/v1/instances/{Uri.EscapeDataString(host)}/history";
|
||||||
|
public static string GET_M_Cdn(string key) => $"api/privapub/v1/cdns/{Uri.EscapeDataString(key)}";
|
||||||
|
|
||||||
public static string GET_M_Provenance(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/provenance";
|
public static string GET_M_Provenance(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/provenance";
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-3
@@ -1,4 +1,4 @@
|
|||||||
using decePubClient.Models.Mastodon;
|
using decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
namespace decePubClient.Models;
|
namespace decePubClient.Models;
|
||||||
|
|
||||||
@@ -65,10 +65,20 @@ public enum LocationSource
|
|||||||
/// <summary>The author's server, known only by country: a point in that country.</summary>
|
/// <summary>The author's server, known only by country: a point in that country.</summary>
|
||||||
Country,
|
Country,
|
||||||
/// <summary>A located post, within its range of the reader: drawn at the reader.</summary>
|
/// <summary>A located post, within its range of the reader: drawn at the reader.</summary>
|
||||||
NearYou
|
NearYou,
|
||||||
|
/// <summary>The author's server is behind a CDN, whose address says nothing of its place: drawn with the CDN's group.</summary>
|
||||||
|
Cdn
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record FeedLocation(double Latitude, double Longitude, LocationSource Source, string Label, double PrecisionKm);
|
/// <summary>
|
||||||
|
/// Where a feed item is drawn. For a CDN-fronted server (Source Cdn) Latitude/Longitude mean nothing: the globe groups it
|
||||||
|
/// under CdnKey, and Before is where the server was before the CDN, when PrivaPub saw it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FeedLocation(double Latitude, double Longitude, LocationSource Source, string Label, double PrecisionKm, string CdnKey = default,
|
||||||
|
InstancePlace Before = default)
|
||||||
|
{
|
||||||
|
public bool HasPoint => Source is not LocationSource.Cdn;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>What the client keeps about a server (from /api/privapub/v1/instances), cached for a day.</summary>
|
/// <summary>What the client keeps about a server (from /api/privapub/v1/instances), cached for a day.</summary>
|
||||||
public class ServerInfo
|
public class ServerInfo
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
namespace decePubClient.Models.Mastodon;
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
@@ -27,8 +27,8 @@ public class InstanceDelivery
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The public projection of a server's place. Precision: city (coordinates to 0.1°, city and network), country (the
|
/// The public projection of a server's place. Precision: city (coordinates to 0.1°, city and network), country (located
|
||||||
/// country only) or cdn (the CDN's name only).
|
/// only to a country) or cdn (the CDN's name, its domain, how PrivaPub found it, and where the server was before it).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class InstanceLocation
|
public class InstanceLocation
|
||||||
{
|
{
|
||||||
@@ -39,10 +39,67 @@ public class InstanceLocation
|
|||||||
public double? Longitude { get; set; }
|
public double? Longitude { get; set; }
|
||||||
public string Network { get; set; }
|
public string Network { get; set; }
|
||||||
public string Cdn { get; set; }
|
public string Cdn { get; set; }
|
||||||
|
public string CdnDomain { get; set; }
|
||||||
|
/// <summary>ranges (the CDN's published addresses), headers (its fingerprint in the server's answers) or asn.</summary>
|
||||||
|
public string CdnSource { get; set; }
|
||||||
|
public InstancePlace BeforeCdn { get; set; }
|
||||||
public string Source { get; set; }
|
public string Source { get; set; }
|
||||||
public DateTime? LocatedAt { get; set; }
|
public DateTime? LocatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Where a server was in a week (an ISO week, "2026-W40").</summary>
|
||||||
|
public class InstancePlace
|
||||||
|
{
|
||||||
|
public string Week { get; set; }
|
||||||
|
public string Country { get; set; }
|
||||||
|
public string City { get; set; }
|
||||||
|
public double? Latitude { get; set; }
|
||||||
|
public double? Longitude { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/privapub/v1/instances/{host}/history: one weekly snapshot of a server.</summary>
|
||||||
|
public class InstanceWeek
|
||||||
|
{
|
||||||
|
public string Week { get; set; }
|
||||||
|
public DateTime? TakenAt { get; set; }
|
||||||
|
public bool Reachable { get; set; }
|
||||||
|
public string Software { get; set; }
|
||||||
|
public string Version { get; set; }
|
||||||
|
public long? UsersTotal { get; set; }
|
||||||
|
public long? UsersActiveMonth { get; set; }
|
||||||
|
public long? LocalPosts { get; set; }
|
||||||
|
public bool? OpenRegistrations { get; set; }
|
||||||
|
public InstanceLocation Geo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/privapub/v1/cdns: a CDN as a group of servers, named by its own domain (Key).</summary>
|
||||||
|
public class CdnSummary
|
||||||
|
{
|
||||||
|
public string Key { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Domain { get; set; }
|
||||||
|
public int Servers { get; set; }
|
||||||
|
public int Ranges { get; set; }
|
||||||
|
public DateTime? RangesUpdatedAt { get; set; }
|
||||||
|
public List<string> DetectedBy { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/privapub/v1/cdns/{key}: the servers behind a CDN and, newest first, week by week who joined and left.</summary>
|
||||||
|
public class CdnDetail : CdnSummary
|
||||||
|
{
|
||||||
|
public List<string> Hosts { get; set; } = [];
|
||||||
|
public bool HostsTruncated { get; set; }
|
||||||
|
public List<CdnWeek> Weeks { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CdnWeek
|
||||||
|
{
|
||||||
|
public string Week { get; set; }
|
||||||
|
public int Servers { get; set; }
|
||||||
|
public List<string> Joined { get; set; } = [];
|
||||||
|
public List<string> Left { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>/api/privapub/v1/statuses/{id}/provenance: how a remote post reached PrivaPub. Path is local for a local post.</summary>
|
/// <summary>/api/privapub/v1/statuses/{id}/provenance: how a remote post reached PrivaPub. Path is local for a local post.</summary>
|
||||||
public class Provenance
|
public class Provenance
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -641,4 +641,43 @@
|
|||||||
<data name="placed" xml:space="preserve">
|
<data name="placed" xml:space="preserve">
|
||||||
<value>collocati</value>
|
<value>collocati</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="weeks known" xml:space="preserve">
|
||||||
|
<value>settimane note</value>
|
||||||
|
</data>
|
||||||
|
<data name="unknown" xml:space="preserve">
|
||||||
|
<value>sconosciuto</value>
|
||||||
|
</data>
|
||||||
|
<data name="unreachable" xml:space="preserve">
|
||||||
|
<value>irraggiungibile</value>
|
||||||
|
</data>
|
||||||
|
<data name="domain" xml:space="preserve">
|
||||||
|
<value>dominio</value>
|
||||||
|
</data>
|
||||||
|
<data name="found by" xml:space="preserve">
|
||||||
|
<value>trovato da</value>
|
||||||
|
</data>
|
||||||
|
<data name="the CDN's published addresses" xml:space="preserve">
|
||||||
|
<value>gli indirizzi pubblicati dalla CDN</value>
|
||||||
|
</data>
|
||||||
|
<data name="the CDN's marks in the server's answers" xml:space="preserve">
|
||||||
|
<value>i segni della CDN nelle risposte del server</value>
|
||||||
|
</data>
|
||||||
|
<data name="a network that carries only the CDN" xml:space="preserve">
|
||||||
|
<value>una rete usata solo dalla CDN</value>
|
||||||
|
</data>
|
||||||
|
<data name="before the CDN" xml:space="preserve">
|
||||||
|
<value>prima della CDN</value>
|
||||||
|
</data>
|
||||||
|
<data name="servers behind it" xml:space="preserve">
|
||||||
|
<value>server dietro</value>
|
||||||
|
</data>
|
||||||
|
<data name="published ranges" xml:space="preserve">
|
||||||
|
<value>intervalli pubblicati</value>
|
||||||
|
</data>
|
||||||
|
<data name="weekly" xml:space="preserve">
|
||||||
|
<value>settimana per settimana</value>
|
||||||
|
</data>
|
||||||
|
<data name="CDN groups" xml:space="preserve">
|
||||||
|
<value>gruppi CDN</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -641,4 +641,43 @@
|
|||||||
<data name="placed" xml:space="preserve">
|
<data name="placed" xml:space="preserve">
|
||||||
<value>placed</value>
|
<value>placed</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="weeks known" xml:space="preserve">
|
||||||
|
<value>weeks known</value>
|
||||||
|
</data>
|
||||||
|
<data name="unknown" xml:space="preserve">
|
||||||
|
<value>unknown</value>
|
||||||
|
</data>
|
||||||
|
<data name="unreachable" xml:space="preserve">
|
||||||
|
<value>unreachable</value>
|
||||||
|
</data>
|
||||||
|
<data name="domain" xml:space="preserve">
|
||||||
|
<value>domain</value>
|
||||||
|
</data>
|
||||||
|
<data name="found by" xml:space="preserve">
|
||||||
|
<value>found by</value>
|
||||||
|
</data>
|
||||||
|
<data name="the CDN's published addresses" xml:space="preserve">
|
||||||
|
<value>the CDN's published addresses</value>
|
||||||
|
</data>
|
||||||
|
<data name="the CDN's marks in the server's answers" xml:space="preserve">
|
||||||
|
<value>the CDN's marks in the server's answers</value>
|
||||||
|
</data>
|
||||||
|
<data name="a network that carries only the CDN" xml:space="preserve">
|
||||||
|
<value>a network that carries only the CDN</value>
|
||||||
|
</data>
|
||||||
|
<data name="before the CDN" xml:space="preserve">
|
||||||
|
<value>before the CDN</value>
|
||||||
|
</data>
|
||||||
|
<data name="servers behind it" xml:space="preserve">
|
||||||
|
<value>servers behind it</value>
|
||||||
|
</data>
|
||||||
|
<data name="published ranges" xml:space="preserve">
|
||||||
|
<value>published ranges</value>
|
||||||
|
</data>
|
||||||
|
<data name="weekly" xml:space="preserve">
|
||||||
|
<value>weekly</value>
|
||||||
|
</data>
|
||||||
|
<data name="CDN groups" xml:space="preserve">
|
||||||
|
<value>CDN groups</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -13,7 +13,8 @@ namespace decePubClient.Services
|
|||||||
/// What PrivaPub knows about the servers the feed shows (/api/privapub/v1/instances, up to 40 at once), cached in
|
/// What PrivaPub knows about the servers the feed shows (/api/privapub/v1/instances, up to 40 at once), cached in
|
||||||
/// memory and localStorage for a day, and where each feed item is drawn. A post is placed, best first: at its own
|
/// memory and localStorage for a day, and where each feed item is drawn. A post is placed, best first: at its own
|
||||||
/// place (Pixelfed), at its event's venue, at the reader for a located post, at its author's server (0.1°), or at a
|
/// place (Pixelfed), at its event's venue, at the reader for a located post, at its author's server (0.1°), or at a
|
||||||
/// point in the server's country when PrivaPub publishes only that. A CDN-fronted server has no place.
|
/// point in the server's country when PrivaPub located it only to a country. A CDN-fronted server has no place: its
|
||||||
|
/// posts join their CDN's group. A server's weekly history and a CDN's page are read on demand, once per session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class InstanceService(IHttpService httpService, ILocalStorageService localStorage, AppConfiguration appConfiguration, ILoggingService logger)
|
public class InstanceService(IHttpService httpService, ILocalStorageService localStorage, AppConfiguration appConfiguration, ILoggingService logger)
|
||||||
{
|
{
|
||||||
@@ -23,6 +24,8 @@ namespace decePubClient.Services
|
|||||||
static readonly TimeSpan UnknownLifetime = TimeSpan.FromHours(1);
|
static readonly TimeSpan UnknownLifetime = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
readonly Dictionary<string, ServerInfo> servers = new(StringComparer.OrdinalIgnoreCase);
|
readonly Dictionary<string, ServerInfo> servers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
readonly Dictionary<string, List<InstanceWeek>> histories = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
readonly Dictionary<string, CdnDetail> cdns = new(StringComparer.OrdinalIgnoreCase);
|
||||||
bool loaded;
|
bool loaded;
|
||||||
string selfHost;
|
string selfHost;
|
||||||
|
|
||||||
@@ -71,6 +74,41 @@ namespace decePubClient.Services
|
|||||||
await Save(cancellationToken);
|
await Save(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<InstanceWeek> KnownHistory(string host) => host is not null ? histories.GetValueOrDefault(host) : null;
|
||||||
|
|
||||||
|
public CdnDetail KnownCdn(string key) => key is not null ? cdns.GetValueOrDefault(key) : null;
|
||||||
|
|
||||||
|
/// <summary>The server's weekly snapshots, newest first; empty when PrivaPub has none.</summary>
|
||||||
|
public async Task<List<InstanceWeek>> History(string host, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (host is null)
|
||||||
|
return [];
|
||||||
|
if (KnownHistory(host) is { } known)
|
||||||
|
return known;
|
||||||
|
|
||||||
|
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_M_InstanceHistory(host), [$"weeks=52"], cancellationToken: cancellationToken);
|
||||||
|
var history = response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync<List<InstanceWeek>>(cancellationToken) ?? [] : [];
|
||||||
|
histories[host] = history;
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A CDN's servers and its weeks, or null when PrivaPub does not know it.</summary>
|
||||||
|
public async Task<CdnDetail> Cdn(string key, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (key is null)
|
||||||
|
return null;
|
||||||
|
if (KnownCdn(key) is { } known)
|
||||||
|
return known;
|
||||||
|
|
||||||
|
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_M_Cdn(key), [$"weeks=12"], cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return null;
|
||||||
|
var cdn = await response.MastodonReadFromJsonAsync<CdnDetail>(cancellationToken);
|
||||||
|
if (cdn is not null)
|
||||||
|
cdns[key] = cdn;
|
||||||
|
return cdn;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Places each post; nearYou is the reader's position, for located posts.</summary>
|
/// <summary>Places each post; nearYou is the reader's position, for located posts.</summary>
|
||||||
public void Locate(IEnumerable<FeedPost> posts, (double Latitude, double Longitude)? nearYou = default)
|
public void Locate(IEnumerable<FeedPost> posts, (double Latitude, double Longitude)? nearYou = default)
|
||||||
{
|
{
|
||||||
@@ -90,6 +128,9 @@ namespace decePubClient.Services
|
|||||||
return new(reader.Latitude, reader.Longitude, LocationSource.NearYou, null, post.RangeKm ?? 5);
|
return new(reader.Latitude, reader.Longitude, LocationSource.NearYou, null, post.RangeKm ?? 5);
|
||||||
|
|
||||||
var geo = Known(post.Host)?.Geo;
|
var geo = Known(post.Host)?.Geo;
|
||||||
|
if (geo?.Cdn is { Length: > 0 } cdn)
|
||||||
|
return new(0, 0, LocationSource.Cdn, cdn, 0, geo.CdnDomain ?? cdn.ToLowerInvariant(), geo.BeforeCdn);
|
||||||
|
|
||||||
if (geo is { Latitude: { } latitude, Longitude: { } longitude })
|
if (geo is { Latitude: { } latitude, Longitude: { } longitude })
|
||||||
return new(latitude, longitude, LocationSource.Server, JoinLabel(geo.City, CountryName(geo.Country)), 11);
|
return new(latitude, longitude, LocationSource.Server, JoinLabel(geo.City, CountryName(geo.Country)), 11);
|
||||||
|
|
||||||
|
|||||||
@@ -76,4 +76,16 @@
|
|||||||
.globe-label span {
|
.globe-label span {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A CDN's group on the globe: a label in orbit, not a place. */
|
||||||
|
.globe-cdn {
|
||||||
|
font: 10px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-base-content);
|
||||||
|
background: color-mix(in oklab, var(--color-base-100) 80%, transparent);
|
||||||
|
box-shadow: 0 1px 6px rgb(0 0 0 / 0.25);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+64
-9
@@ -2,7 +2,9 @@
|
|||||||
// time a globe is shown). It looks at the average place of the located posts in view: the spherical mean of their
|
// time a globe is shown). It looks at the average place of the located posts in view: the spherical mean of their
|
||||||
// points, so posts on either side of the antimeridian average correctly, from an altitude that fits their spread.
|
// points, so posts on either side of the antimeridian average correctly, from an altitude that fits their spread.
|
||||||
// Hovering a post pulses a ring where it is and draws an arc to it from PrivaPub's server; leaving it goes back to the
|
// Hovering a post pulses a ring where it is and draws an arc to it from PrivaPub's server; leaving it goes back to the
|
||||||
// view. Day texture with the light theme, night with the dark one; motion follows prefers-reduced-motion, and nothing
|
// view. Posts of servers behind a CDN have no place: they are grouped per CDN in labels on a ring above the Arctic,
|
||||||
|
// which says "CDN" rather than any place; hovering one draws its arc to that group and a ring where the server was
|
||||||
|
// before the CDN, when PrivaPub saw it there. Day texture with the light theme, night with the dark one; motion follows prefers-reduced-motion, and nothing
|
||||||
// renders while the page or the globe is hidden. No .NET round-trip on scroll or hover: the posts' places come in one
|
// renders while the page or the globe is hidden. No .NET round-trip on scroll or hover: the posts' places come in one
|
||||||
// call per feed change (setPosts), the cards are matched by data-post-id.
|
// call per feed change (setPosts), the cards are matched by data-post-id.
|
||||||
|
|
||||||
@@ -59,12 +61,13 @@ export function create(element, scrollContainerId) {
|
|||||||
watchVisibility()
|
watchVisibility()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The located posts of the feed: [{ key, lat, lng, src, label, host, serverLat, serverLng, range }], and PrivaPub's
|
// The located posts of the feed: [{ key, lat, lng, src, label, host, serverLat, serverLng, range, cdn, beforeLat,
|
||||||
// own place ({ lat, lng, label } or null), where every arc starts.
|
// beforeLng, beforeLabel }] (src "cdn" posts have no lat/lng, only their CDN's key), and PrivaPub's own place
|
||||||
|
// ({ lat, lng, label } or null), where every arc starts.
|
||||||
export function setPosts(posts, self) {
|
export function setPosts(posts, self) {
|
||||||
if (!state)
|
if (!state)
|
||||||
return
|
return
|
||||||
state.points = new Map(posts.filter(p => Number.isFinite(p.lat) && Number.isFinite(p.lng)).map(p => [p.key, p]))
|
state.points = new Map(posts.filter(p => p.src === "cdn" ? !!p.cdn : Number.isFinite(p.lat) && Number.isFinite(p.lng)).map(p => [p.key, p]))
|
||||||
state.self = self && Number.isFinite(self.lat) && Number.isFinite(self.lng) ? self : null
|
state.self = self && Number.isFinite(self.lat) && Number.isFinite(self.lng) ? self : null
|
||||||
drawPoints()
|
drawPoints()
|
||||||
scheduleView()
|
scheduleView()
|
||||||
@@ -75,7 +78,7 @@ export function stats() {
|
|||||||
if (!state?.globe)
|
if (!state?.globe)
|
||||||
return state?.error ? { error: state.error } : null
|
return state?.error ? { error: state.error } : null
|
||||||
const camera = state.globe.pointOfView()
|
const camera = state.globe.pointOfView()
|
||||||
const visibleLocated = [...state.visible].filter(key => state.points.has(key)).length
|
const visibleLocated = [...state.visible].filter(key => placed(state.points.get(key))).length
|
||||||
return {
|
return {
|
||||||
latitude: round(camera.lat, 2),
|
latitude: round(camera.lat, 2),
|
||||||
longitude: round(camera.lng, 2),
|
longitude: round(camera.lng, 2),
|
||||||
@@ -88,6 +91,7 @@ export function stats() {
|
|||||||
points: aggregate().length,
|
points: aggregate().length,
|
||||||
posts: state.points.size,
|
posts: state.points.size,
|
||||||
servers: new Set([...state.points.values()].map(p => p.host)).size,
|
servers: new Set([...state.points.values()].map(p => p.host)).size,
|
||||||
|
cdnGroups: cdnGroups().length,
|
||||||
fps: state.fps,
|
fps: state.fps,
|
||||||
renderer: state.renderer,
|
renderer: state.renderer,
|
||||||
texture: state.texture,
|
texture: state.texture,
|
||||||
@@ -159,10 +163,14 @@ async function build(width, height) {
|
|||||||
.arcDashGap(0.25)
|
.arcDashGap(0.25)
|
||||||
.arcDashInitialGap(1)
|
.arcDashInitialGap(1)
|
||||||
.arcDashAnimateTime(reducedMotion.matches ? 0 : 1600)
|
.arcDashAnimateTime(reducedMotion.matches ? 0 : 1600)
|
||||||
|
.htmlLat(g => g.lat)
|
||||||
|
.htmlLng(g => g.lng)
|
||||||
|
.htmlAltitude(0.12)
|
||||||
|
.htmlElement(cdnLabel)
|
||||||
const controls = globe.controls()
|
const controls = globe.controls()
|
||||||
controls.autoRotateSpeed = 0.35
|
controls.autoRotateSpeed = 0.35
|
||||||
controls.enableDamping = true
|
controls.enableDamping = true
|
||||||
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.5 * narrowness() }, 0)
|
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * narrowness() }, 0)
|
||||||
applyTheme()
|
applyTheme()
|
||||||
readRenderer()
|
readRenderer()
|
||||||
countFrames()
|
countFrames()
|
||||||
@@ -293,10 +301,37 @@ function colour(kind, alpha = 0.95) {
|
|||||||
return `rgba(${rgb.join(",")},${alpha})`
|
return `rgba(${rgb.join(",")},${alpha})`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A post with a place on Earth (not a CDN-fronted server's).
|
||||||
|
function placed(post) {
|
||||||
|
return !!post && post.src !== "cdn"
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CDN groups, each at its own longitude on a ring at 68°N, ordered by key so they keep their places.
|
||||||
|
function cdnGroups() {
|
||||||
|
const byKey = new Map()
|
||||||
|
for (const post of state.points.values()) {
|
||||||
|
if (post.src !== "cdn")
|
||||||
|
continue
|
||||||
|
const group = byKey.get(post.cdn) ?? { key: post.cdn, name: post.label, hosts: new Set(), count: 0 }
|
||||||
|
group.count++
|
||||||
|
if (post.host)
|
||||||
|
group.hosts.add(post.host)
|
||||||
|
byKey.set(post.cdn, group)
|
||||||
|
}
|
||||||
|
const groups = [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key))
|
||||||
|
groups.forEach((group, i) => {
|
||||||
|
group.lat = 68
|
||||||
|
group.lng = -180 + (i + 0.5) * 360 / groups.length
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
// Posts at the same place are one point; PrivaPub's own server is a point of its own.
|
// Posts at the same place are one point; PrivaPub's own server is a point of its own.
|
||||||
function aggregate() {
|
function aggregate() {
|
||||||
const byPlace = new Map()
|
const byPlace = new Map()
|
||||||
for (const post of state.points.values()) {
|
for (const post of state.points.values()) {
|
||||||
|
if (!placed(post))
|
||||||
|
continue
|
||||||
const kind = post.src === "event" ? "place" : post.src
|
const kind = post.src === "event" ? "place" : post.src
|
||||||
const id = `${kind}:${post.lat.toFixed(2)},${post.lng.toFixed(2)}`
|
const id = `${kind}:${post.lat.toFixed(2)},${post.lng.toFixed(2)}`
|
||||||
const point = byPlace.get(id) ?? { lat: post.lat, lng: post.lng, src: kind, label: post.label, hosts: new Set(), count: 0 }
|
const point = byPlace.get(id) ?? { lat: post.lat, lng: post.lng, src: kind, label: post.label, hosts: new Set(), count: 0 }
|
||||||
@@ -314,10 +349,20 @@ function aggregate() {
|
|||||||
function drawPoints() {
|
function drawPoints() {
|
||||||
if (!state?.globe)
|
if (!state?.globe)
|
||||||
return
|
return
|
||||||
|
state.groups = cdnGroups()
|
||||||
state.globe.pointsData(aggregate())
|
state.globe.pointsData(aggregate())
|
||||||
|
state.globe.htmlElementsData(state.groups)
|
||||||
drawRings()
|
drawRings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cdnLabel(group) {
|
||||||
|
const element = document.createElement("div")
|
||||||
|
element.className = "globe-cdn"
|
||||||
|
element.textContent = `☁ ${group.name} · ${group.count}`
|
||||||
|
element.title = [...group.hosts].slice(0, 10).join("\n")
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
function label(point) {
|
function label(point) {
|
||||||
const escape = text => String(text ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c])
|
const escape = text => String(text ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c])
|
||||||
const hosts = [...point.hosts].slice(0, 4).map(escape).join("<br>")
|
const hosts = [...point.hosts].slice(0, 4).map(escape).join("<br>")
|
||||||
@@ -332,8 +377,10 @@ function drawRings() {
|
|||||||
return
|
return
|
||||||
const rings = []
|
const rings = []
|
||||||
const hovered = state.hovered && state.points.get(state.hovered)
|
const hovered = state.hovered && state.points.get(state.hovered)
|
||||||
if (hovered)
|
if (placed(hovered))
|
||||||
rings.push({ lat: hovered.lat, lng: hovered.lng, radius: 4, speed: 3, period: reducedMotion.matches ? 100000 : 900, colour: colour(hovered.src, 1) })
|
rings.push({ lat: hovered.lat, lng: hovered.lng, radius: 4, speed: 3, period: reducedMotion.matches ? 100000 : 900, colour: colour(hovered.src, 1) })
|
||||||
|
else if (hovered && Number.isFinite(hovered.beforeLat))
|
||||||
|
rings.push({ lat: hovered.beforeLat, lng: hovered.beforeLng, radius: 3, speed: 1.5, period: reducedMotion.matches ? 100000 : 1400, colour: colour("country", 1) })
|
||||||
const reach = Math.max(0, ...[...state.points.values()].filter(p => p.src === "nearyou").map(p => p.range ?? 0))
|
const reach = Math.max(0, ...[...state.points.values()].filter(p => p.src === "nearyou").map(p => p.range ?? 0))
|
||||||
const reader = [...state.points.values()].find(p => p.src === "nearyou")
|
const reader = [...state.points.values()].find(p => p.src === "nearyou")
|
||||||
if (reader && reach > 0)
|
if (reader && reach > 0)
|
||||||
@@ -352,6 +399,14 @@ function hover(key) {
|
|||||||
fly(state.view, 900)
|
fly(state.view, 900)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!placed(post)) {
|
||||||
|
const group = state.groups?.find(g => g.key === post.cdn)
|
||||||
|
state.globe.arcsData(group && state.self ? [{ startLat: state.self.lat, startLng: state.self.lng, endLat: group.lat, endLng: group.lng }] : [])
|
||||||
|
const target = Number.isFinite(post.beforeLat) ? { lat: post.beforeLat, lng: post.beforeLng } : group
|
||||||
|
if (target && distance(state.globe.pointOfView(), target) > 2500)
|
||||||
|
fly({ lat: target.lat, lng: target.lng, altitude: state.globe.pointOfView().altitude }, 900)
|
||||||
|
return
|
||||||
|
}
|
||||||
const from = post.src === "place" || post.src === "event"
|
const from = post.src === "place" || post.src === "event"
|
||||||
? (Number.isFinite(post.serverLat) ? { lat: post.serverLat, lng: post.serverLng } : state.self)
|
? (Number.isFinite(post.serverLat) ? { lat: post.serverLat, lng: post.serverLng } : state.self)
|
||||||
: state.self
|
: state.self
|
||||||
@@ -373,7 +428,7 @@ function scheduleView() {
|
|||||||
function updateView() {
|
function updateView() {
|
||||||
if (!state?.globe)
|
if (!state?.globe)
|
||||||
return
|
return
|
||||||
const located = [...state.visible].map(key => state.points.get(key)).filter(Boolean)
|
const located = [...state.visible].map(key => state.points.get(key)).filter(placed)
|
||||||
const controls = state.globe.controls()
|
const controls = state.globe.controls()
|
||||||
if (located.length === 0) {
|
if (located.length === 0) {
|
||||||
state.view = null
|
state.view = null
|
||||||
@@ -394,7 +449,7 @@ function updateView() {
|
|||||||
lng: Math.atan2(y, x) * 180 / Math.PI
|
lng: Math.atan2(y, x) * 180 / Math.PI
|
||||||
}
|
}
|
||||||
state.spreadKm = Math.max(0, ...located.map(p => distance(mean, p)))
|
state.spreadKm = Math.max(0, ...located.map(p => distance(mean, p)))
|
||||||
mean.altitude = Math.min(3.2, Math.max(1.1, 1.1 + state.spreadKm / 3500) * narrowness())
|
mean.altitude = Math.min(3.6, (1.5 + state.spreadKm / 3500) * narrowness())
|
||||||
state.view = mean
|
state.view = mean
|
||||||
if (!state.hovered)
|
if (!state.hovered)
|
||||||
fly(mean, 1200)
|
fly(mean, 1200)
|
||||||
|
|||||||
Reference in new issue
Block a user