diff --git a/CLAUDE.md b/CLAUDE.md index 7b90769..64b7e4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,19 +189,24 @@ supplies: `PrivaPub.ClientModels`. - **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 - `Helpers/CountryPoints.cs` when DB-IP located it only to a country), or nowhere (a CDN-fronted server). - Server descriptions are cached a day in localStorage (`ServerInfos`). PrivaPub's own host is the signed-in persona's - account host, since the API may be reached under another name. + `Helpers/CountryPoints.cs` when DB-IP located it only to a country), or, for a CDN-fronted server, its CDN's group + (`LocationSource.Cdn`: no point, a `CdnKey` and the place `Before` the CDN). Server descriptions are cached a day in + 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`, `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 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 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`. - **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. **Localization (`Services/CoalescingStringLocalizer.cs`).** diff --git a/Components/Content.razor b/Components/Content.razor index 18900af..8e1a510 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -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. 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 - quoted post (Compact) has none, since it is not an item of the feed. Located - posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on the time, the author, the place, - the media and the counts. *@ + quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only + its CDN's group (data-cdn). Located posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on + the time, the author and its server's history, the place or the CDN, the media and the counts. *@
@@ -53,14 +54,24 @@ @if (Status is { } status) {
- + @status.Account?.Name @("@" + status.Account?.Acct) - @if (Post.Location is { } location) + @if (Post.Location is { Source: LocationSource.Cdn } fronted) + { + + + + @fronted.Label + + + } + else if (Post.Location is { } location) { @@ -69,13 +80,6 @@ } - else if (ServerGeo?.Cdn is { Length: > 0 } cdn) - { - - - @cdn - - } @@ -235,6 +239,7 @@ bool isOptionsOpen; bool isSpoilerOpen; bool isReadingProvenance; + bool isReadingServer; readonly HashSet revealedMedia = []; Status Status => Post.Status; @@ -408,6 +413,18 @@ 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 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["failures in a row"], server.ConsecutiveFailures > 0 ? server.ConsecutiveFailures : null); 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 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 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 LocationRows() diff --git a/Components/Feed/GlobePanel.razor b/Components/Feed/GlobePanel.razor index f1f0c00..cd7e578 100644 --- a/Components/Feed/GlobePanel.razor +++ b/Components/Feed/GlobePanel.razor @@ -75,7 +75,7 @@ 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}"; + 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; @@ -83,8 +83,10 @@ 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); + 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)); } @@ -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["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); @@ -154,7 +157,8 @@ 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); @@ -171,6 +175,7 @@ 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; } diff --git a/MastodonJsonContext.cs b/MastodonJsonContext.cs index fe7d73f..f7fff3a 100644 --- a/MastodonJsonContext.cs +++ b/MastodonJsonContext.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using decePubClient.Models.Mastodon; @@ -24,6 +24,9 @@ namespace decePubClient; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(InstanceDescription))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(CdnDetail))] [JsonSerializable(typeof(Provenance))] [JsonSerializable(typeof(TokenResponse))] [JsonSerializable(typeof(MastodonError))] diff --git a/Models/APIs.cs b/Models/APIs.cs index bb1f7c6..9d28236 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -1,4 +1,4 @@ -namespace decePubClient.Models; +namespace decePubClient.Models; /// /// 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 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"; } diff --git a/Models/FeedPost.cs b/Models/FeedPost.cs index 251f07a..40b0bf3 100644 --- a/Models/FeedPost.cs +++ b/Models/FeedPost.cs @@ -1,4 +1,4 @@ -using decePubClient.Models.Mastodon; +using decePubClient.Models.Mastodon; namespace decePubClient.Models; @@ -65,10 +65,20 @@ public enum LocationSource /// The author's server, known only by country: a point in that country. Country, /// A located post, within its range of the reader: drawn at the reader. - NearYou + NearYou, + /// The author's server is behind a CDN, whose address says nothing of its place: drawn with the CDN's group. + Cdn } -public sealed record FeedLocation(double Latitude, double Longitude, LocationSource Source, string Label, double PrecisionKm); +/// +/// 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. +/// +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; +} /// What the client keeps about a server (from /api/privapub/v1/instances), cached for a day. public class ServerInfo diff --git a/Models/Mastodon/PrivaPubApi.cs b/Models/Mastodon/PrivaPubApi.cs index ada9955..5e31017 100644 --- a/Models/Mastodon/PrivaPubApi.cs +++ b/Models/Mastodon/PrivaPubApi.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Nodes; +using System.Text.Json.Nodes; namespace decePubClient.Models.Mastodon; @@ -27,8 +27,8 @@ public class InstanceDelivery } /// -/// The public projection of a server's place. Precision: city (coordinates to 0.1°, city and network), country (the -/// country only) or cdn (the CDN's name only). +/// The public projection of a server's place. Precision: city (coordinates to 0.1°, city and network), country (located +/// only to a country) or cdn (the CDN's name, its domain, how PrivaPub found it, and where the server was before it). /// public class InstanceLocation { @@ -39,10 +39,67 @@ public class InstanceLocation public double? Longitude { get; set; } public string Network { get; set; } public string Cdn { get; set; } + public string CdnDomain { get; set; } + /// ranges (the CDN's published addresses), headers (its fingerprint in the server's answers) or asn. + public string CdnSource { get; set; } + public InstancePlace BeforeCdn { get; set; } public string Source { get; set; } public DateTime? LocatedAt { get; set; } } +/// Where a server was in a week (an ISO week, "2026-W40"). +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; } +} + +/// /api/privapub/v1/instances/{host}/history: one weekly snapshot of a server. +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; } +} + +/// /api/privapub/v1/cdns: a CDN as a group of servers, named by its own domain (Key). +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 DetectedBy { get; set; } = []; +} + +/// /api/privapub/v1/cdns/{key}: the servers behind a CDN and, newest first, week by week who joined and left. +public class CdnDetail : CdnSummary +{ + public List Hosts { get; set; } = []; + public bool HostsTruncated { get; set; } + public List Weeks { get; set; } = []; +} + +public class CdnWeek +{ + public string Week { get; set; } + public int Servers { get; set; } + public List Joined { get; set; } = []; + public List Left { get; set; } = []; +} + /// /api/privapub/v1/statuses/{id}/provenance: how a remote post reached PrivaPub. Path is local for a local post. public class Provenance { diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 74e3acb..80d3a8a 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -641,4 +641,43 @@ collocati + + settimane note + + + sconosciuto + + + irraggiungibile + + + dominio + + + trovato da + + + gli indirizzi pubblicati dalla CDN + + + i segni della CDN nelle risposte del server + + + una rete usata solo dalla CDN + + + prima della CDN + + + server dietro + + + intervalli pubblicati + + + settimana per settimana + + + gruppi CDN + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index fefd810..0b3ef28 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -641,4 +641,43 @@ placed + + weeks known + + + unknown + + + unreachable + + + domain + + + found by + + + the CDN's published addresses + + + the CDN's marks in the server's answers + + + a network that carries only the CDN + + + before the CDN + + + servers behind it + + + published ranges + + + weekly + + + CDN groups + diff --git a/Services/InstanceService.cs b/Services/InstanceService.cs index 0e7cb92..52965cc 100644 --- a/Services/InstanceService.cs +++ b/Services/InstanceService.cs @@ -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 /// 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 - /// 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. /// 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); readonly Dictionary servers = new(StringComparer.OrdinalIgnoreCase); + readonly Dictionary> histories = new(StringComparer.OrdinalIgnoreCase); + readonly Dictionary cdns = new(StringComparer.OrdinalIgnoreCase); bool loaded; string selfHost; @@ -71,6 +74,41 @@ namespace decePubClient.Services await Save(cancellationToken); } + public List KnownHistory(string host) => host is not null ? histories.GetValueOrDefault(host) : null; + + public CdnDetail KnownCdn(string key) => key is not null ? cdns.GetValueOrDefault(key) : null; + + /// The server's weekly snapshots, newest first; empty when PrivaPub has none. + public async Task> 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>(cancellationToken) ?? [] : []; + histories[host] = history; + return history; + } + + /// A CDN's servers and its weeks, or null when PrivaPub does not know it. + public async Task 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(cancellationToken); + if (cdn is not null) + cdns[key] = cdn; + return cdn; + } + /// Places each post; nearYou is the reader's position, for located posts. public void Locate(IEnumerable 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); 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 }) return new(latitude, longitude, LocationSource.Server, JoinLabel(geo.City, CountryName(geo.Country)), 11); diff --git a/Styles/feed.css b/Styles/feed.css index 29f12bb..be16ecd 100644 --- a/Styles/feed.css +++ b/Styles/feed.css @@ -76,4 +76,16 @@ .globe-label span { 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; + } } diff --git a/wwwroot/js/globe.js b/wwwroot/js/globe.js index 6ce39fe..0a4d38d 100644 --- a/wwwroot/js/globe.js +++ b/wwwroot/js/globe.js @@ -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 // 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 -// 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 // call per feed change (setPosts), the cards are matched by data-post-id. @@ -59,12 +61,13 @@ export function create(element, scrollContainerId) { watchVisibility() } -// The located posts of the feed: [{ key, lat, lng, src, label, host, serverLat, serverLng, range }], and PrivaPub's -// own place ({ lat, lng, label } or null), where every arc starts. +// The located posts of the feed: [{ key, lat, lng, src, label, host, serverLat, serverLng, range, cdn, beforeLat, +// 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) { if (!state) 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 drawPoints() scheduleView() @@ -75,7 +78,7 @@ export function stats() { if (!state?.globe) return state?.error ? { error: state.error } : null 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 { latitude: round(camera.lat, 2), longitude: round(camera.lng, 2), @@ -88,6 +91,7 @@ export function stats() { points: aggregate().length, posts: state.points.size, servers: new Set([...state.points.values()].map(p => p.host)).size, + cdnGroups: cdnGroups().length, fps: state.fps, renderer: state.renderer, texture: state.texture, @@ -159,10 +163,14 @@ async function build(width, height) { .arcDashGap(0.25) .arcDashInitialGap(1) .arcDashAnimateTime(reducedMotion.matches ? 0 : 1600) + .htmlLat(g => g.lat) + .htmlLng(g => g.lng) + .htmlAltitude(0.12) + .htmlElement(cdnLabel) const controls = globe.controls() controls.autoRotateSpeed = 0.35 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() readRenderer() countFrames() @@ -293,10 +301,37 @@ function colour(kind, alpha = 0.95) { 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. function aggregate() { const byPlace = new Map() for (const post of state.points.values()) { + if (!placed(post)) + continue const kind = post.src === "event" ? "place" : post.src 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 } @@ -314,10 +349,20 @@ function aggregate() { function drawPoints() { if (!state?.globe) return + state.groups = cdnGroups() state.globe.pointsData(aggregate()) + state.globe.htmlElementsData(state.groups) 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) { const escape = text => String(text ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]) const hosts = [...point.hosts].slice(0, 4).map(escape).join("
") @@ -332,8 +377,10 @@ function drawRings() { return const rings = [] 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) }) + 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 reader = [...state.points.values()].find(p => p.src === "nearyou") if (reader && reach > 0) @@ -352,6 +399,14 @@ function hover(key) { fly(state.view, 900) 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" ? (Number.isFinite(post.serverLat) ? { lat: post.serverLat, lng: post.serverLng } : state.self) : state.self @@ -373,7 +428,7 @@ function scheduleView() { function updateView() { if (!state?.globe) 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() if (located.length === 0) { state.view = null @@ -394,7 +449,7 @@ function updateView() { lng: Math.atan2(y, x) * 180 / Math.PI } 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 if (!state.hovered) fly(mean, 1200)