Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f68e74e38 | ||
|
|
69d0a641fe |
No files matched your search
@@ -198,16 +198,31 @@ supplies:
|
|||||||
`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 and `countries.json` (`SOURCES.md` has versions, licences and hashes; the
|
||||||
precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an arc to a hovered
|
service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an
|
||||||
post, follows the theme (day/night texture), honours `prefers-reduced-motion` and pauses while hidden. CDN-fronted
|
arc to a hovered post, honours `prefers-reduced-motion`, and draws only while something moves (idle, it costs no GPU
|
||||||
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
|
time; `wake()` before anything that animates).
|
||||||
was before the CDN. The posts'
|
- **Map.** Each pixel computes its own map coordinates from its point on the sphere (`sharpen`), so the poles don't
|
||||||
places reach it in one call per feed change; cards are matched by `data-post-id`.
|
pinch; the maps wrap horizontally and get 8× anisotropic filtering (`keepSharp`). The day texture is NASA's, its
|
||||||
|
seam repaired by `tools/globe/prepare-day-texture.py` (dev-only, needs Pillow).
|
||||||
|
- **Sun.** `wwwroot/js/sun.js` (pure functions of a date: the subsolar point, Earth–Sun distance, seasons, a place's
|
||||||
|
elevation, phase of the day, solar time and day length) lights the globe in the shader: the day map shaded by the
|
||||||
|
Sun's height and distance, city lights on the night side, a twilight blend between. It shows now (refreshed every
|
||||||
|
30 s), or the moment of the hovered, focused or pinned post (`data-post-at` on the card; a click pins a card,
|
||||||
|
`data-globe-pinned`), and tells `GlobePanel` through `SunChanged`, which shows the moment, the phase and the season
|
||||||
|
in the panel's top corner. The theme changes only the atmosphere and the points.
|
||||||
|
- **Countries.** Land borders as thin paths, and names placed by `placeLabels`: largest countries first, once they
|
||||||
|
span enough pixels, away from the globe's edge and clear of each other, in the reader's language
|
||||||
|
(`Intl.DisplayNames`). `countries.json` is built by `tools/globe/prepare-countries.py` from world-atlas (Natural
|
||||||
|
Earth). The label renderer owns `display`, so labels hide through `visibility`.
|
||||||
|
- 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
|
- **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on
|
||||||
hover), its author and server with the server's changes of place and CDN week by week, its place or its CDN (how
|
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,
|
PrivaPub found it, the place before it, the CDN's servers week by week), media and counts; the feed's paging and last
|
||||||
points and frame rate.
|
request; the globe's camera, points, countries and frame rate, and its Sun (subsolar point, distance, irradiance,
|
||||||
|
the place's elevation and day length, the next equinox or solstice).
|
||||||
|
|
||||||
**Localization (`Services/CoalescingStringLocalizer.cs`).**
|
**Localization (`Services/CoalescingStringLocalizer.cs`).**
|
||||||
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
|
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
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. A CDN-fronted server's post has no point, only
|
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
|
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun. 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. *@
|
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 || Post.Location?.HasPoint != true ? 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 || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Longitude))"
|
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-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)" data-post-at="@(Compact ? null : Post.WrittenAt?.ToString("O"))">
|
||||||
|
|
||||||
@if (Author is { } author)
|
@if (Author is { } author)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,18 +1,45 @@
|
|||||||
@inherits LocalizableComponentBase
|
@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
|
@* The feed's 3D globe (wwwroot/js/globe.js on the vendored globe.gl): it looks at the average place of the posts in
|
||||||
view and shows where a hovered post is. The posts' places go to the module in one call whenever they change; the
|
view and shows where a hovered post is, lit by the Sun of now or of the moment the hovered, focused or pinned post
|
||||||
corner shows the camera and the frame rate, its tooltip the rest of the globe's numbers. *@
|
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. *@
|
||||||
<div class="relative w-full h-full">
|
<div class="relative w-full h-full">
|
||||||
<div @ref="element" class="absolute inset-0" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
|
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
|
||||||
|
|
||||||
<div class="absolute left-2 bottom-2 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
|
@if (stats?.Sun is { } sun)
|
||||||
|
{
|
||||||
|
<div class="absolute left-2 top-2 z-10 max-w-[75%] text-[11px] 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="absolute left-2 bottom-2 z-10 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
|
||||||
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
|
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
|
||||||
<span class="badge badge-ghost badge-xs gap-1">
|
<span class="badge badge-ghost badge-xs gap-1">
|
||||||
<i class="ion-md-planet"></i>
|
<i class="ion-md-planet"></i>
|
||||||
@if (stats is { Error: null } live)
|
@if (stats is { Error: null } live)
|
||||||
{
|
{
|
||||||
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @live.Fps fps</span>
|
<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 })
|
else if (stats?.Error is { Length: > 0 })
|
||||||
{
|
{
|
||||||
@@ -37,6 +64,7 @@
|
|||||||
|
|
||||||
ElementReference element;
|
ElementReference element;
|
||||||
IJSObjectReference module;
|
IJSObjectReference module;
|
||||||
|
DotNetObjectReference<GlobePanel> reference;
|
||||||
PeriodicTimer statsTimer;
|
PeriodicTimer statsTimer;
|
||||||
readonly CancellationTokenSource cancellation = new();
|
readonly CancellationTokenSource cancellation = new();
|
||||||
string sentFingerprint;
|
string sentFingerprint;
|
||||||
@@ -52,7 +80,9 @@
|
|||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
|
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/globe.js");
|
||||||
await module.InvokeVoidAsync("create", element, ScrollContainerId);
|
reference = DotNetObjectReference.Create(this);
|
||||||
|
await module.InvokeVoidAsync("create", element, ScrollContainerId, reference,
|
||||||
|
new { Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName });
|
||||||
statsTimer = new(TimeSpan.FromSeconds(1));
|
statsTimer = new(TimeSpan.FromSeconds(1));
|
||||||
_ = ReadStats(cancellation.Token);
|
_ = ReadStats(cancellation.Token);
|
||||||
}
|
}
|
||||||
@@ -100,6 +130,26 @@
|
|||||||
: null;
|
: 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)
|
async Task ReadStats(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -129,20 +179,85 @@
|
|||||||
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["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.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["auto-rotate"], stats.AutoRotate);
|
||||||
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048" : null);
|
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("WebGL", stats.Renderer);
|
||||||
yield return NerdRow.Of(Localizer["error"], stats.Error);
|
yield return NerdRow.Of(Localizer["error"], stats.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
static string Fixed(double? value) => value?.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture) ?? "–";
|
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()
|
public override async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await cancellation.CancelAsync();
|
await cancellation.CancelAsync();
|
||||||
cancellation.Dispose();
|
cancellation.Dispose();
|
||||||
statsTimer?.Dispose();
|
statsTimer?.Dispose();
|
||||||
|
reference?.Dispose();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (module is not null)
|
if (module is not null)
|
||||||
@@ -179,11 +294,44 @@
|
|||||||
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; }
|
||||||
|
public int Anisotropy { get; set; }
|
||||||
public bool AutoRotate { get; set; }
|
public bool AutoRotate { get; set; }
|
||||||
public bool Paused { get; set; }
|
public bool Paused { get; set; }
|
||||||
|
public bool Idle { get; set; }
|
||||||
public string Hovered { get; set; }
|
public string Hovered { get; set; }
|
||||||
public int Arcs { get; set; }
|
public int Arcs { get; set; }
|
||||||
public int Rings { 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; }
|
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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,11 @@ public class FeedPost
|
|||||||
|
|
||||||
public string Host => Author?.Host;
|
public string Host => Author?.Host;
|
||||||
|
|
||||||
|
/// <summary>When the post was written (for a follow, when it happened), in UTC; the globe shows the Sun of that moment.</summary>
|
||||||
|
public DateTime? WrittenAt => (Status?.CreatedAt ?? Notification?.CreatedAt) is { } at
|
||||||
|
? (at.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(at, DateTimeKind.Utc) : at.ToUniversalTime())
|
||||||
|
: null;
|
||||||
|
|
||||||
public bool IsOwnedBy(string personaId) => personaId is not null && Status?.Account?.Id == personaId;
|
public bool IsOwnedBy(string personaId) => personaId is not null && Status?.Account?.Id == personaId;
|
||||||
|
|
||||||
public static FeedPost Of(Status status) => status.Reblog is { } original
|
public static FeedPost Of(Status status) => status.Reblog is { } original
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<root>
|
<root>
|
||||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
@@ -680,4 +680,130 @@
|
|||||||
<data name="CDN groups" xml:space="preserve">
|
<data name="CDN groups" xml:space="preserve">
|
||||||
<value>gruppi CDN</value>
|
<value>gruppi CDN</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="idle" xml:space="preserve">
|
||||||
|
<value>fermo</value>
|
||||||
|
</data>
|
||||||
|
<data name="idle: nothing moves, nothing is drawn" xml:space="preserve">
|
||||||
|
<value>fermo: nulla si muove, nulla viene disegnato</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sun" xml:space="preserve">
|
||||||
|
<value>Sole</value>
|
||||||
|
</data>
|
||||||
|
<data name="Now" xml:space="preserve">
|
||||||
|
<value>Ora</value>
|
||||||
|
</data>
|
||||||
|
<data name="When it was written" xml:space="preserve">
|
||||||
|
<value>Quando è stato scritto</value>
|
||||||
|
</data>
|
||||||
|
<data name="solar time {0}" xml:space="preserve">
|
||||||
|
<value>ora solare {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="northern hemisphere" xml:space="preserve">
|
||||||
|
<value>emisfero boreale</value>
|
||||||
|
</data>
|
||||||
|
<data name="southern hemisphere" xml:space="preserve">
|
||||||
|
<value>emisfero australe</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} in the north, {1} in the south" xml:space="preserve">
|
||||||
|
<value>{0} a nord, {1} a sud</value>
|
||||||
|
</data>
|
||||||
|
<data name="countries" xml:space="preserve">
|
||||||
|
<value>paesi</value>
|
||||||
|
</data>
|
||||||
|
<data name="named at this zoom" xml:space="preserve">
|
||||||
|
<value>con nome a questo zoom</value>
|
||||||
|
</data>
|
||||||
|
<data name="borders" xml:space="preserve">
|
||||||
|
<value>confini</value>
|
||||||
|
</data>
|
||||||
|
<data name="lines" xml:space="preserve">
|
||||||
|
<value>linee</value>
|
||||||
|
</data>
|
||||||
|
<data name="moment" xml:space="preserve">
|
||||||
|
<value>momento</value>
|
||||||
|
</data>
|
||||||
|
<data name="subsolar point" xml:space="preserve">
|
||||||
|
<value>punto subsolare</value>
|
||||||
|
</data>
|
||||||
|
<data name="declination" xml:space="preserve">
|
||||||
|
<value>declinazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="axial tilt" xml:space="preserve">
|
||||||
|
<value>inclinazione assiale</value>
|
||||||
|
</data>
|
||||||
|
<data name="ecliptic longitude" xml:space="preserve">
|
||||||
|
<value>longitudine eclittica</value>
|
||||||
|
</data>
|
||||||
|
<data name="Earth–Sun distance" xml:space="preserve">
|
||||||
|
<value>distanza Terra–Sole</value>
|
||||||
|
</data>
|
||||||
|
<data name="irradiance" xml:space="preserve">
|
||||||
|
<value>irradianza</value>
|
||||||
|
</data>
|
||||||
|
<data name="elevation" xml:space="preserve">
|
||||||
|
<value>elevazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="day length" xml:space="preserve">
|
||||||
|
<value>durata del giorno</value>
|
||||||
|
</data>
|
||||||
|
<data name="seasons" xml:space="preserve">
|
||||||
|
<value>stagioni</value>
|
||||||
|
</data>
|
||||||
|
<data name="next" xml:space="preserve">
|
||||||
|
<value>prossimo</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} in {1} days" xml:space="preserve">
|
||||||
|
<value>{0} tra {1} giorni</value>
|
||||||
|
</data>
|
||||||
|
<data name="daylight" xml:space="preserve">
|
||||||
|
<value>giorno</value>
|
||||||
|
</data>
|
||||||
|
<data name="night" xml:space="preserve">
|
||||||
|
<value>notte</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0}, {1}" xml:space="preserve">
|
||||||
|
<value>{0}, {1}</value>
|
||||||
|
</data>
|
||||||
|
<data name="golden hour" xml:space="preserve">
|
||||||
|
<value>ora dorata</value>
|
||||||
|
</data>
|
||||||
|
<data name="civil twilight" xml:space="preserve">
|
||||||
|
<value>crepuscolo civile</value>
|
||||||
|
</data>
|
||||||
|
<data name="nautical twilight" xml:space="preserve">
|
||||||
|
<value>crepuscolo nautico</value>
|
||||||
|
</data>
|
||||||
|
<data name="astronomical twilight" xml:space="preserve">
|
||||||
|
<value>crepuscolo astronomico</value>
|
||||||
|
</data>
|
||||||
|
<data name="morning" xml:space="preserve">
|
||||||
|
<value>mattina</value>
|
||||||
|
</data>
|
||||||
|
<data name="evening" xml:space="preserve">
|
||||||
|
<value>sera</value>
|
||||||
|
</data>
|
||||||
|
<data name="spring" xml:space="preserve">
|
||||||
|
<value>primavera</value>
|
||||||
|
</data>
|
||||||
|
<data name="summer" xml:space="preserve">
|
||||||
|
<value>estate</value>
|
||||||
|
</data>
|
||||||
|
<data name="autumn" xml:space="preserve">
|
||||||
|
<value>autunno</value>
|
||||||
|
</data>
|
||||||
|
<data name="winter" xml:space="preserve">
|
||||||
|
<value>inverno</value>
|
||||||
|
</data>
|
||||||
|
<data name="March equinox" xml:space="preserve">
|
||||||
|
<value>equinozio di marzo</value>
|
||||||
|
</data>
|
||||||
|
<data name="June solstice" xml:space="preserve">
|
||||||
|
<value>solstizio di giugno</value>
|
||||||
|
</data>
|
||||||
|
<data name="September equinox" xml:space="preserve">
|
||||||
|
<value>equinozio di settembre</value>
|
||||||
|
</data>
|
||||||
|
<data name="December solstice" xml:space="preserve">
|
||||||
|
<value>solstizio di dicembre</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
+127
-1
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<root>
|
<root>
|
||||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
@@ -680,4 +680,130 @@
|
|||||||
<data name="CDN groups" xml:space="preserve">
|
<data name="CDN groups" xml:space="preserve">
|
||||||
<value>CDN groups</value>
|
<value>CDN groups</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="idle" xml:space="preserve">
|
||||||
|
<value>idle</value>
|
||||||
|
</data>
|
||||||
|
<data name="idle: nothing moves, nothing is drawn" xml:space="preserve">
|
||||||
|
<value>idle: nothing moves, nothing is drawn</value>
|
||||||
|
</data>
|
||||||
|
<data name="Sun" xml:space="preserve">
|
||||||
|
<value>Sun</value>
|
||||||
|
</data>
|
||||||
|
<data name="Now" xml:space="preserve">
|
||||||
|
<value>Now</value>
|
||||||
|
</data>
|
||||||
|
<data name="When it was written" xml:space="preserve">
|
||||||
|
<value>When it was written</value>
|
||||||
|
</data>
|
||||||
|
<data name="solar time {0}" xml:space="preserve">
|
||||||
|
<value>solar time {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="northern hemisphere" xml:space="preserve">
|
||||||
|
<value>northern hemisphere</value>
|
||||||
|
</data>
|
||||||
|
<data name="southern hemisphere" xml:space="preserve">
|
||||||
|
<value>southern hemisphere</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} in the north, {1} in the south" xml:space="preserve">
|
||||||
|
<value>{0} in the north, {1} in the south</value>
|
||||||
|
</data>
|
||||||
|
<data name="countries" xml:space="preserve">
|
||||||
|
<value>countries</value>
|
||||||
|
</data>
|
||||||
|
<data name="named at this zoom" xml:space="preserve">
|
||||||
|
<value>named at this zoom</value>
|
||||||
|
</data>
|
||||||
|
<data name="borders" xml:space="preserve">
|
||||||
|
<value>borders</value>
|
||||||
|
</data>
|
||||||
|
<data name="lines" xml:space="preserve">
|
||||||
|
<value>lines</value>
|
||||||
|
</data>
|
||||||
|
<data name="moment" xml:space="preserve">
|
||||||
|
<value>moment</value>
|
||||||
|
</data>
|
||||||
|
<data name="subsolar point" xml:space="preserve">
|
||||||
|
<value>subsolar point</value>
|
||||||
|
</data>
|
||||||
|
<data name="declination" xml:space="preserve">
|
||||||
|
<value>declination</value>
|
||||||
|
</data>
|
||||||
|
<data name="axial tilt" xml:space="preserve">
|
||||||
|
<value>axial tilt</value>
|
||||||
|
</data>
|
||||||
|
<data name="ecliptic longitude" xml:space="preserve">
|
||||||
|
<value>ecliptic longitude</value>
|
||||||
|
</data>
|
||||||
|
<data name="Earth–Sun distance" xml:space="preserve">
|
||||||
|
<value>Earth–Sun distance</value>
|
||||||
|
</data>
|
||||||
|
<data name="irradiance" xml:space="preserve">
|
||||||
|
<value>irradiance</value>
|
||||||
|
</data>
|
||||||
|
<data name="elevation" xml:space="preserve">
|
||||||
|
<value>elevation</value>
|
||||||
|
</data>
|
||||||
|
<data name="day length" xml:space="preserve">
|
||||||
|
<value>day length</value>
|
||||||
|
</data>
|
||||||
|
<data name="seasons" xml:space="preserve">
|
||||||
|
<value>seasons</value>
|
||||||
|
</data>
|
||||||
|
<data name="next" xml:space="preserve">
|
||||||
|
<value>next</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} in {1} days" xml:space="preserve">
|
||||||
|
<value>{0} in {1} days</value>
|
||||||
|
</data>
|
||||||
|
<data name="daylight" xml:space="preserve">
|
||||||
|
<value>daylight</value>
|
||||||
|
</data>
|
||||||
|
<data name="night" xml:space="preserve">
|
||||||
|
<value>night</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0}, {1}" xml:space="preserve">
|
||||||
|
<value>{0}, {1}</value>
|
||||||
|
</data>
|
||||||
|
<data name="golden hour" xml:space="preserve">
|
||||||
|
<value>golden hour</value>
|
||||||
|
</data>
|
||||||
|
<data name="civil twilight" xml:space="preserve">
|
||||||
|
<value>civil twilight</value>
|
||||||
|
</data>
|
||||||
|
<data name="nautical twilight" xml:space="preserve">
|
||||||
|
<value>nautical twilight</value>
|
||||||
|
</data>
|
||||||
|
<data name="astronomical twilight" xml:space="preserve">
|
||||||
|
<value>astronomical twilight</value>
|
||||||
|
</data>
|
||||||
|
<data name="morning" xml:space="preserve">
|
||||||
|
<value>morning</value>
|
||||||
|
</data>
|
||||||
|
<data name="evening" xml:space="preserve">
|
||||||
|
<value>evening</value>
|
||||||
|
</data>
|
||||||
|
<data name="spring" xml:space="preserve">
|
||||||
|
<value>spring</value>
|
||||||
|
</data>
|
||||||
|
<data name="summer" xml:space="preserve">
|
||||||
|
<value>summer</value>
|
||||||
|
</data>
|
||||||
|
<data name="autumn" xml:space="preserve">
|
||||||
|
<value>autumn</value>
|
||||||
|
</data>
|
||||||
|
<data name="winter" xml:space="preserve">
|
||||||
|
<value>winter</value>
|
||||||
|
</data>
|
||||||
|
<data name="March equinox" xml:space="preserve">
|
||||||
|
<value>March equinox</value>
|
||||||
|
</data>
|
||||||
|
<data name="June solstice" xml:space="preserve">
|
||||||
|
<value>June solstice</value>
|
||||||
|
</data>
|
||||||
|
<data name="September equinox" xml:space="preserve">
|
||||||
|
<value>September equinox</value>
|
||||||
|
</data>
|
||||||
|
<data name="December solstice" xml:space="preserve">
|
||||||
|
<value>December solstice</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
+22
-2
@@ -1,6 +1,6 @@
|
|||||||
/* The feed: post HTML as PrivaPub's Mastodon API sends it (already sanitized; Mastodon's link markup hides the
|
/* The feed: post HTML as PrivaPub's Mastodon API sends it (already sanitized; Mastodon's link markup hides the
|
||||||
scheme in .invisible and shortens long links with .ellipsis), and the globe's point labels, which replace the
|
scheme in .invisible and shortens long links with .ellipsis), the globe's point labels, which replace the look of
|
||||||
look of globe.gl's own tooltip box. */
|
globe.gl's own tooltip box, its CDN groups and country names, and the card it is pinned to. */
|
||||||
@layer components {
|
@layer components {
|
||||||
.post-content p {
|
.post-content p {
|
||||||
margin-block: 0 0.5em;
|
margin-block: 0 0.5em;
|
||||||
@@ -88,4 +88,24 @@
|
|||||||
box-shadow: 0 1px 6px rgb(0 0 0 / 0.25);
|
box-shadow: 0 1px 6px rgb(0 0 0 / 0.25);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A country's name on the globe, on the land by day and by night alike: light text in a dark halo. The globe sets
|
||||||
|
its size and opacity by zoom, and hides it through visibility (the label renderer owns display). */
|
||||||
|
.globe-country {
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: rgb(255 255 255 / 0.9);
|
||||||
|
text-shadow: 0 0 2px rgb(0 0 0 / 0.95), 0 0 6px rgb(0 0 0 / 0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The card the globe is pinned to (a click on it): its moment and place stay on the globe. */
|
||||||
|
[data-globe-pinned] {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Executable
+184
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Builds wwwroot/vendor/globe/countries.json, the globe's country borders and name positions, from world-atlas 2.0.2's
|
||||||
|
# countries-50m.json (Natural Earth 1:50m, public domain; packaged under ISC by Mike Bostock), downloaded once from
|
||||||
|
# https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz
|
||||||
|
# usage: tools/globe/prepare-countries.py <countries-50m.json> <iso_3166-1.json> <output json> (not part of the build)
|
||||||
|
# iso_3166-1.json is the iso-codes package's (/usr/share/iso-codes/json/iso_3166-1.json); it maps world-atlas's numeric
|
||||||
|
# ids to the two-letter codes the browser names countries by (Intl.DisplayNames), so names follow the reader's language.
|
||||||
|
#
|
||||||
|
# Output: {"borders": [[lng, lat, lng, lat, ...], ...], "countries": [{"code", "name", "lat", "lng", "area"}, ...]}
|
||||||
|
# - borders are the arcs two countries share, land borders only: coastlines are in the texture already. Coordinates are
|
||||||
|
# rounded to 0.01° (about a kilometre).
|
||||||
|
# - each country's name sits in its largest polygon, at the point nearest the polygon's centroid that is still at least
|
||||||
|
# half as far from the edges as the pole of inaccessibility (the point farthest from them, as Mapbox's polylabel finds
|
||||||
|
# it): inside the country even for a crescent, and in the middle of a long one (central Italy, not the Po valley).
|
||||||
|
# - area is the country's, size the square root of its largest polygon's (where the name is), both in degrees scaled by
|
||||||
|
# the cosine of the latitude; the globe shows a name once its polygon is large enough on screen to hold it.
|
||||||
|
import heapq
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def decode(topology):
|
||||||
|
scale_x, scale_y = topology["transform"]["scale"]
|
||||||
|
translate_x, translate_y = topology["transform"]["translate"]
|
||||||
|
arcs = []
|
||||||
|
for arc in topology["arcs"]:
|
||||||
|
x = y = 0
|
||||||
|
points = []
|
||||||
|
for dx, dy in arc:
|
||||||
|
x += dx
|
||||||
|
y += dy
|
||||||
|
points.append((x * scale_x + translate_x, y * scale_y + translate_y))
|
||||||
|
arcs.append(points)
|
||||||
|
return arcs
|
||||||
|
|
||||||
|
|
||||||
|
def ring(arcs, indexes):
|
||||||
|
points = []
|
||||||
|
for index in indexes:
|
||||||
|
arc = arcs[index] if index >= 0 else list(reversed(arcs[~index]))
|
||||||
|
points.extend(arc if not points else arc[1:])
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def polygons(geometry):
|
||||||
|
if geometry["type"] == "Polygon":
|
||||||
|
return [geometry["arcs"]]
|
||||||
|
if geometry["type"] == "MultiPolygon":
|
||||||
|
return geometry["arcs"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def planar(points, latitude):
|
||||||
|
# an equirectangular plane squeezed by the cosine of the polygon's latitude, so distances are roughly true there
|
||||||
|
k = math.cos(math.radians(latitude))
|
||||||
|
return [(x * k, y) for x, y in points]
|
||||||
|
|
||||||
|
|
||||||
|
def area(points):
|
||||||
|
return abs(sum(x1 * y2 - x2 * y1 for (x1, y1), (x2, y2) in zip(points, points[1:] + points[:1]))) / 2
|
||||||
|
|
||||||
|
|
||||||
|
def distance_to_rings(x, y, rings):
|
||||||
|
inside = False
|
||||||
|
best = math.inf
|
||||||
|
for points in rings:
|
||||||
|
for (ax, ay), (bx, by) in zip(points, points[1:] + points[:1]):
|
||||||
|
if (ay > y) != (by > y) and x < (bx - ax) * (y - ay) / (by - ay) + ax:
|
||||||
|
inside = not inside
|
||||||
|
dx, dy = bx - ax, by - ay
|
||||||
|
t = 0 if dx == dy == 0 else max(0, min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy)))
|
||||||
|
best = min(best, (x - ax - t * dx) ** 2 + (y - ay - t * dy) ** 2)
|
||||||
|
return (1 if inside else -1) * math.sqrt(best)
|
||||||
|
|
||||||
|
|
||||||
|
def polylabel(rings, precision=0.05):
|
||||||
|
xs = [x for x, _ in rings[0]]
|
||||||
|
ys = [y for _, y in rings[0]]
|
||||||
|
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
|
||||||
|
size = min(max_x - min_x, max_y - min_y)
|
||||||
|
if size == 0:
|
||||||
|
return min_x, min_y
|
||||||
|
half = size / 2
|
||||||
|
cells = []
|
||||||
|
|
||||||
|
def push(x, y, h):
|
||||||
|
d = distance_to_rings(x, y, rings)
|
||||||
|
heapq.heappush(cells, (-(d + h * math.sqrt(2)), d, x, y, h))
|
||||||
|
|
||||||
|
x = min_x
|
||||||
|
while x < max_x:
|
||||||
|
y = min_y
|
||||||
|
while y < max_y:
|
||||||
|
push(x + half, y + half, half)
|
||||||
|
y += size
|
||||||
|
x += size
|
||||||
|
best_d = distance_to_rings((min_x + max_x) / 2, (min_y + max_y) / 2, rings)
|
||||||
|
best = ((min_x + max_x) / 2, (min_y + max_y) / 2)
|
||||||
|
while cells:
|
||||||
|
bound, d, x, y, h = heapq.heappop(cells)
|
||||||
|
if d > best_d:
|
||||||
|
best_d, best = d, (x, y)
|
||||||
|
if -bound - best_d <= precision:
|
||||||
|
continue
|
||||||
|
h /= 2
|
||||||
|
for sx in (-1, 1):
|
||||||
|
for sy in (-1, 1):
|
||||||
|
push(x + sx * h, y + sy * h, h)
|
||||||
|
return best, best_d
|
||||||
|
|
||||||
|
|
||||||
|
def centroid(points):
|
||||||
|
twice = 0
|
||||||
|
x = y = 0
|
||||||
|
for (x1, y1), (x2, y2) in zip(points, points[1:] + points[:1]):
|
||||||
|
cross = x1 * y2 - x2 * y1
|
||||||
|
twice += cross
|
||||||
|
x += (x1 + x2) * cross
|
||||||
|
y += (y1 + y2) * cross
|
||||||
|
if twice == 0:
|
||||||
|
return points[0]
|
||||||
|
return x / (3 * twice), y / (3 * twice)
|
||||||
|
|
||||||
|
|
||||||
|
def label_point(rings):
|
||||||
|
(best_x, best_y), best_d = polylabel(rings)
|
||||||
|
if best_d <= 0:
|
||||||
|
return best_x, best_y
|
||||||
|
cx, cy = centroid(rings[0])
|
||||||
|
xs = [x for x, _ in rings[0]]
|
||||||
|
ys = [y for _, y in rings[0]]
|
||||||
|
step = max(max(xs) - min(xs), max(ys) - min(ys)) / 80
|
||||||
|
chosen, chosen_distance = (best_x, best_y), math.hypot(best_x - cx, best_y - cy)
|
||||||
|
x = min(xs)
|
||||||
|
while x <= max(xs):
|
||||||
|
y = min(ys)
|
||||||
|
while y <= max(ys):
|
||||||
|
to_centroid = math.hypot(x - cx, y - cy)
|
||||||
|
if to_centroid < chosen_distance and distance_to_rings(x, y, rings) >= best_d / 2:
|
||||||
|
chosen, chosen_distance = (x, y), to_centroid
|
||||||
|
y += step
|
||||||
|
x += step
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
|
topology = json.load(open(sys.argv[1]))
|
||||||
|
codes = {entry["numeric"]: entry["alpha_2"] for entry in json.load(open(sys.argv[2]))["3166-1"]}
|
||||||
|
arcs = decode(topology)
|
||||||
|
geometries = topology["objects"]["countries"]["geometries"]
|
||||||
|
|
||||||
|
uses = [0] * len(arcs)
|
||||||
|
for geometry in geometries:
|
||||||
|
for polygon in polygons(geometry):
|
||||||
|
for indexes in polygon:
|
||||||
|
for index in indexes:
|
||||||
|
uses[index if index >= 0 else ~index] += 1
|
||||||
|
|
||||||
|
borders = [[round(c, 2) for point in arc for c in point] for arc, used in zip(arcs, uses) if used >= 2]
|
||||||
|
|
||||||
|
countries = []
|
||||||
|
for geometry in geometries:
|
||||||
|
shapes = []
|
||||||
|
for polygon in polygons(geometry):
|
||||||
|
outer = ring(arcs, polygon[0])
|
||||||
|
latitude = sum(y for _, y in outer) / len(outer)
|
||||||
|
shapes.append((area(planar(outer, latitude)), latitude, [ring(arcs, r) for r in polygon]))
|
||||||
|
if not shapes:
|
||||||
|
continue
|
||||||
|
largest, latitude, rings = max(shapes, key=lambda s: s[0])
|
||||||
|
k = math.cos(math.radians(latitude))
|
||||||
|
x, y = label_point([planar(r, latitude) for r in rings])
|
||||||
|
countries.append({
|
||||||
|
"code": codes.get(str(geometry.get("id", "")).zfill(3)),
|
||||||
|
"name": geometry.get("properties", {}).get("name"),
|
||||||
|
"lat": round(y, 2),
|
||||||
|
"lng": round(x / k, 2),
|
||||||
|
"area": round(sum(s[0] for s in shapes), 1),
|
||||||
|
"size": round(math.sqrt(largest), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
json.dump({"borders": borders, "countries": sorted(countries, key=lambda c: -c["area"])}, open(sys.argv[3], "w"), separators=(",", ":"))
|
||||||
|
print(f"{len(borders)} border lines ({sum(len(b) // 2 for b in borders)} points), {len(countries)} countries "
|
||||||
|
f"({sum(1 for c in countries if c['code'] is None)} without an ISO code)")
|
||||||
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Builds wwwroot/vendor/globe/earth-blue-marble.jpg from NASA's Blue Marble Next Generation (topography and bathymetry,
|
||||||
|
# December 2004, public domain), downloaded once:
|
||||||
|
# https://eoimages.gsfc.nasa.gov/images/imagerecords/73000/73909/world.topo.bathy.200412.3x5400x2700.jpg
|
||||||
|
# usage: tools/globe/prepare-day-texture.py <that jpg> <output jpg> (needs Pillow; not part of the build)
|
||||||
|
#
|
||||||
|
# NASA's mosaic does not wrap cleanly: towards the poles a strip at its east edge is lighter than the map on both sides
|
||||||
|
# of the 180° meridian, which the globe draws as a line from each pole. On every row where the map agrees on both sides
|
||||||
|
# of the seam but the strip around it does not, the strip is replaced by a blend between the two sides. A real feature
|
||||||
|
# on the meridian (Fiji, the Ross Ice Shelf) makes the two sides differ, so its rows are left alone. Within half a degree
|
||||||
|
# of each pole a row is a circle a few kilometres across, so it gets one colour, its median, which removes the strip
|
||||||
|
# there too. Then the map is scaled to 4096×2048 (the globe's texture size) with Lanczos.
|
||||||
|
import sys
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
BAND = 16 # pixels on each side of the seam that may be repaired
|
||||||
|
ANCHOR = 4 # pixels averaged just outside the band, on each side
|
||||||
|
AGREE = 24 # the two sides agree when their mean channels differ by less than this
|
||||||
|
ODD = 30 # a band pixel is an artefact when it differs from the blend by more than this
|
||||||
|
POLAR = 0.5 # degrees from a pole within which a row is one colour
|
||||||
|
|
||||||
|
|
||||||
|
def mean(pixels):
|
||||||
|
return tuple(sum(p[i] for p in pixels) / len(pixels) for i in range(3))
|
||||||
|
|
||||||
|
|
||||||
|
def distance(a, b):
|
||||||
|
return sum(abs(x - y) for x, y in zip(a, b)) / 3
|
||||||
|
|
||||||
|
|
||||||
|
source, target = sys.argv[1], sys.argv[2]
|
||||||
|
image = Image.open(source).convert("RGB")
|
||||||
|
width, height = image.size
|
||||||
|
pixels = image.load()
|
||||||
|
repaired = 0
|
||||||
|
for y in range(height):
|
||||||
|
west = mean([pixels[width - BAND - 1 - i, y] for i in range(ANCHOR)])
|
||||||
|
east = mean([pixels[BAND + i, y] for i in range(ANCHOR)])
|
||||||
|
if distance(west, east) >= AGREE:
|
||||||
|
continue
|
||||||
|
columns = [width - BAND + i for i in range(BAND)] + list(range(BAND))
|
||||||
|
blend = [tuple(w + (e - w) * (i + 1) / (2 * BAND + 1) for w, e in zip(west, east)) for i in range(2 * BAND)]
|
||||||
|
if max(distance(pixels[x, y], b) for x, b in zip(columns, blend)) <= ODD:
|
||||||
|
continue
|
||||||
|
for x, b in zip(columns, blend):
|
||||||
|
pixels[x, y] = tuple(round(c) for c in b)
|
||||||
|
repaired += 1
|
||||||
|
|
||||||
|
polar = 0
|
||||||
|
for y in range(height):
|
||||||
|
latitude = 90 - (y + 0.5) * 180 / height
|
||||||
|
if abs(latitude) < 90 - POLAR:
|
||||||
|
continue
|
||||||
|
row = [pixels[x, y] for x in range(width)]
|
||||||
|
median = tuple(sorted(p[i] for p in row)[len(row) // 2] for i in range(3))
|
||||||
|
for x in range(width):
|
||||||
|
pixels[x, y] = median
|
||||||
|
polar += 1
|
||||||
|
|
||||||
|
image.resize((4096, 2048), Image.LANCZOS).save(target, "JPEG", quality=90, optimize=True, progressive=True)
|
||||||
|
print(f"{repaired} of {height} rows repaired at the seam, {polar} polar rows made one colour; wrote {target}")
|
||||||
+436
-23
@@ -4,12 +4,22 @@
|
|||||||
// 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. Posts of servers behind a CDN have no place: they are grouped per CDN in labels on a ring above the Arctic,
|
// 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
|
// 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
|
// before the CDN, when PrivaPub saw it there.
|
||||||
// renders while the page or the globe is hidden. No .NET round-trip on scroll or hover: the posts' places come in one
|
// The Earth is lit by the Sun where it really is (sun.js): the day map where the Sun is up, shaded by its height, the
|
||||||
// call per feed change (setPosts), the cards are matched by data-post-id.
|
// night map (city lights) where it is down, blended across a twilight band, and brighter or dimmer with the Earth–Sun
|
||||||
|
// distance. It shows now, or, while a post is hovered or focused, the moment that post was written; the Sun moves there
|
||||||
|
// and back smoothly. Land borders are thin lines, and country names sit inside each country in the reader's language,
|
||||||
|
// more of them and larger as the camera comes closer. Motion follows prefers-reduced-motion. It renders only while
|
||||||
|
// something moves (a camera flight, the Sun moving, a hovered post's pulse, auto-rotation, the reader dragging or
|
||||||
|
// zooming) and never while the page or the globe is hidden: an idle globe costs no GPU time. 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, and .NET only hears that the Sun's moment changed (SunChanged), to show it.
|
||||||
|
|
||||||
|
import { directionOf, seasonsOf, sunAt, sunAtPlace } from "./sun.js"
|
||||||
|
|
||||||
const vendor = new URL("../vendor/globe/", import.meta.url).href
|
const vendor = new URL("../vendor/globe/", import.meta.url).href
|
||||||
const textures = { day: vendor + "earth-blue-marble.jpg", night: vendor + "earth-night.jpg" }
|
const textures = { day: vendor + "earth-blue-marble.jpg", night: vendor + "earth-night.jpg" }
|
||||||
|
const countriesUrl = vendor + "countries.json"
|
||||||
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)")
|
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)")
|
||||||
|
|
||||||
let loading = null
|
let loading = null
|
||||||
@@ -29,12 +39,27 @@ function loadLibrary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Creates the globe in element once it has a size (it may start hidden on a phone), following the cards of the
|
// Creates the globe in element once it has a size (it may start hidden on a phone), following the cards of the
|
||||||
// scroll container.
|
// scroll container. reference is the .NET panel (SunChanged); options.language names the countries.
|
||||||
export function create(element, scrollContainerId) {
|
export function create(element, scrollContainerId, reference, options) {
|
||||||
dispose()
|
dispose()
|
||||||
|
const now = sunAt(new Date())
|
||||||
state = {
|
state = {
|
||||||
element,
|
element,
|
||||||
|
reference,
|
||||||
|
names: regionNames(options?.language),
|
||||||
scroller: document.getElementById(scrollContainerId),
|
scroller: document.getElementById(scrollContainerId),
|
||||||
|
sun: now,
|
||||||
|
sunPoint: { lat: now.lat, lng: now.lng },
|
||||||
|
sunUniforms: { sunDirection: { value: directionOf(now) }, sunIntensity: { value: now.irradiance }, nightMap: { value: null }, nightReady: { value: 0 } },
|
||||||
|
focus: null,
|
||||||
|
pinned: null,
|
||||||
|
nightImage: null,
|
||||||
|
countries: [],
|
||||||
|
borders: [],
|
||||||
|
labelOf: new Map(),
|
||||||
|
labelKey: null,
|
||||||
|
labelsAt: 0,
|
||||||
|
countryNames: 0,
|
||||||
globe: null,
|
globe: null,
|
||||||
points: new Map(),
|
points: new Map(),
|
||||||
self: null,
|
self: null,
|
||||||
@@ -47,6 +72,10 @@ export function create(element, scrollContainerId) {
|
|||||||
renderer: null,
|
renderer: null,
|
||||||
texture: null,
|
texture: null,
|
||||||
paused: false,
|
paused: false,
|
||||||
|
idle: false,
|
||||||
|
running: true,
|
||||||
|
interacting: false,
|
||||||
|
awakeUntil: 0,
|
||||||
observers: [],
|
observers: [],
|
||||||
listeners: [],
|
listeners: [],
|
||||||
timers: [],
|
timers: [],
|
||||||
@@ -71,6 +100,7 @@ export function setPosts(posts, self) {
|
|||||||
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()
|
||||||
|
wake()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live numbers for the nerd stats overlay; null until the globe exists.
|
// Live numbers for the nerd stats overlay; null until the globe exists.
|
||||||
@@ -94,12 +124,50 @@ export function stats() {
|
|||||||
cdnGroups: cdnGroups().length,
|
cdnGroups: cdnGroups().length,
|
||||||
fps: state.fps,
|
fps: state.fps,
|
||||||
renderer: state.renderer,
|
renderer: state.renderer,
|
||||||
texture: state.texture,
|
texture: state.sunUniforms.nightReady.value ? "day+night" : "day",
|
||||||
|
anisotropy: state.anisotropy,
|
||||||
autoRotate: !!state.globe.controls().autoRotate,
|
autoRotate: !!state.globe.controls().autoRotate,
|
||||||
paused: state.paused,
|
paused: state.paused,
|
||||||
|
idle: state.idle,
|
||||||
hovered: state.hovered && state.points.has(state.hovered) ? state.points.get(state.hovered).label ?? state.hovered : null,
|
hovered: state.hovered && state.points.has(state.hovered) ? state.points.get(state.hovered).label ?? state.hovered : null,
|
||||||
arcs: state.globe.arcsData().length,
|
arcs: state.globe.arcsData().length,
|
||||||
rings: state.globe.ringsData().length
|
rings: state.globe.ringsData().length,
|
||||||
|
countries: state.countries.length,
|
||||||
|
countryNames: state.countryNames,
|
||||||
|
borders: state.borders.length,
|
||||||
|
sun: sunStats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Sun's moment as the overlay shows it: now, or the focused post's moment and what the Sun does at its place.
|
||||||
|
function sunStats() {
|
||||||
|
const sun = state.sun
|
||||||
|
const focus = state.focus
|
||||||
|
const place = focus && Number.isFinite(focus.lat) ? sunAtPlace(sun, focus.lat, focus.lng) : null
|
||||||
|
const seasons = seasonsOf(sun)
|
||||||
|
return {
|
||||||
|
at: sun.date.toISOString(),
|
||||||
|
now: !focus,
|
||||||
|
latitude: round(sun.lat, 2),
|
||||||
|
longitude: round(sun.lng, 2),
|
||||||
|
distance: round(sun.distance, 5),
|
||||||
|
irradiance: round(sun.irradiance, 4),
|
||||||
|
eclipticLongitude: round(sun.eclipticLongitude, 2),
|
||||||
|
obliquity: round(sun.obliquity, 3),
|
||||||
|
place: focus?.label ?? null,
|
||||||
|
placeLatitude: focus && Number.isFinite(focus.lat) ? round(focus.lat, 2) : null,
|
||||||
|
placeLongitude: focus && Number.isFinite(focus.lng) ? round(focus.lng, 2) : null,
|
||||||
|
elevation: place ? round(place.elevation, 1) : null,
|
||||||
|
phase: place?.phase ?? null,
|
||||||
|
morning: place?.morning ?? null,
|
||||||
|
solarTime: place?.solarTime ?? null,
|
||||||
|
dayLength: place ? round(place.dayLength, 2) : null,
|
||||||
|
season: place?.season ?? null,
|
||||||
|
hemisphere: place?.hemisphere ?? null,
|
||||||
|
seasonNorth: seasons.north,
|
||||||
|
seasonSouth: seasons.south,
|
||||||
|
nextEvent: seasons.nextEvent,
|
||||||
|
nextEventDays: seasons.nextEventDays
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +197,7 @@ function resize() {
|
|||||||
}
|
}
|
||||||
state.globe.width(width).height(height)
|
state.globe.width(width).height(height)
|
||||||
pause(document.visibilityState !== "visible")
|
pause(document.visibilityState !== "visible")
|
||||||
|
wake()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function build(width, height) {
|
async function build(width, height) {
|
||||||
@@ -163,14 +232,45 @@ 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)
|
.htmlLat(d => d.lat)
|
||||||
.htmlLng(g => g.lng)
|
.htmlLng(d => d.lng)
|
||||||
.htmlAltitude(0.12)
|
.htmlAltitude(d => d.kind === "country" ? 0.004 : 0.12)
|
||||||
.htmlElement(cdnLabel)
|
.htmlElement(d => d.kind === "country" ? countryLabel(d) : cdnLabel(d))
|
||||||
|
.htmlTransitionDuration(0)
|
||||||
|
// the label renderer sets display on every frame, so labels hide through visibility; country names are
|
||||||
|
// placed by placeLabels
|
||||||
|
.htmlElementVisibilityModifier((element, inFront) => {
|
||||||
|
if (!element.classList.contains("globe-country"))
|
||||||
|
element.style.visibility = inFront ? "" : "hidden"
|
||||||
|
})
|
||||||
|
.pathPoints(b => b.points)
|
||||||
|
.pathPointLat(p => p[0])
|
||||||
|
.pathPointLng(p => p[1])
|
||||||
|
.pathPointAlt(0.0015)
|
||||||
|
.pathColor(() => "rgba(255,255,255,0.32)")
|
||||||
|
.pathStroke(null)
|
||||||
|
.pathTransitionDuration(0)
|
||||||
|
.globeImageUrl(textures.day)
|
||||||
|
sharpen(globe)
|
||||||
|
loadNight()
|
||||||
|
loadCountries()
|
||||||
const controls = globe.controls()
|
const controls = globe.controls()
|
||||||
controls.autoRotateSpeed = 0.35
|
controls.autoRotateSpeed = 0.35
|
||||||
controls.enableDamping = true
|
controls.enableDamping = true
|
||||||
|
controls.addEventListener("start", () => {
|
||||||
|
state.interacting = true
|
||||||
|
wake()
|
||||||
|
})
|
||||||
|
controls.addEventListener("end", () => {
|
||||||
|
state.interacting = false
|
||||||
|
wake(1500)
|
||||||
|
})
|
||||||
|
listen(state.element, "pointermove", () => wake(1000))
|
||||||
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * narrowness() }, 0)
|
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * narrowness() }, 0)
|
||||||
|
state.timers.push(setInterval(() => {
|
||||||
|
if (!state.focus)
|
||||||
|
aimSun(sunAt(new Date()))
|
||||||
|
}, 30000))
|
||||||
applyTheme()
|
applyTheme()
|
||||||
readRenderer()
|
readRenderer()
|
||||||
countFrames()
|
countFrames()
|
||||||
@@ -187,6 +287,262 @@ async function build(width, height) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The globe is a UV sphere: towards the poles its triangles narrow to slivers meeting at one point, and texture
|
||||||
|
// coordinates interpolated across them pinch the map into a star around the axis. Each pixel computes its own instead,
|
||||||
|
// from its exact point on the sphere (the inverse of three.js SphereGeometry's mapping), so the map is drawn the same
|
||||||
|
// everywhere. The mipmap level comes from gradients taken on whichever of two longitude ranges has no jump at that pixel,
|
||||||
|
// and the map wraps around horizontally (keepSharp), so the 180° meridian shows no seam; anisotropic filtering keeps
|
||||||
|
// high latitudes sharp at grazing angles, at 8× at most: sharper than that is barely visible and costs on phones.
|
||||||
|
// The Sun replaces the scene's lights: the day map where it is up, shaded by its height (cosine of its zenith angle,
|
||||||
|
// eased so the low sun does not go black at once) and scaled by the Earth–Sun distance; where it is down, the night
|
||||||
|
// map's city lights, warmed to sodium's colour, over a dim trace of that map's land; the two blended across twilight,
|
||||||
|
// from 7° below the horizon to 6° above, with a faint warm band along the terminator.
|
||||||
|
function sharpen(globe) {
|
||||||
|
const material = globe.globeMaterial()
|
||||||
|
material.onBeforeCompile = shader => {
|
||||||
|
Object.assign(shader.uniforms, state.sunUniforms)
|
||||||
|
shader.vertexShader = shader.vertexShader
|
||||||
|
.replace("#include <common>", "#include <common>\nvarying vec3 vSpherePoint;")
|
||||||
|
.replace("#include <begin_vertex>", "#include <begin_vertex>\nvSpherePoint = position;")
|
||||||
|
shader.fragmentShader = shader.fragmentShader
|
||||||
|
.replace("#include <common>", `#include <common>
|
||||||
|
varying vec3 vSpherePoint;
|
||||||
|
uniform vec3 sunDirection;
|
||||||
|
uniform float sunIntensity;
|
||||||
|
uniform sampler2D nightMap;
|
||||||
|
uniform float nightReady;`)
|
||||||
|
.replace("#include <opaque_fragment>", `
|
||||||
|
#ifdef USE_MAP
|
||||||
|
gl_FragColor = vec4(earthColor, diffuseColor.a);
|
||||||
|
#else
|
||||||
|
#include <opaque_fragment>
|
||||||
|
#endif`)
|
||||||
|
.replace("#include <map_fragment>", `
|
||||||
|
vec3 earthColor = vec3(0.0);
|
||||||
|
#ifdef USE_MAP
|
||||||
|
vec3 spherePoint = normalize(vSpherePoint);
|
||||||
|
float sphereU = atan(spherePoint.z, -spherePoint.x) / (2.0 * PI);
|
||||||
|
float sphereV = 1.0 - acos(clamp(spherePoint.y, -1.0, 1.0)) / PI;
|
||||||
|
float uWhole = fract(sphereU);
|
||||||
|
float uShifted = fract(sphereU + 0.5) - 0.5;
|
||||||
|
vec2 gradientX = vec2(dFdx(uWhole), dFdx(sphereV));
|
||||||
|
vec2 gradientY = vec2(dFdy(uWhole), dFdy(sphereV));
|
||||||
|
float shiftedX = dFdx(uShifted);
|
||||||
|
float shiftedY = dFdy(uShifted);
|
||||||
|
if (abs(shiftedX) + abs(shiftedY) < abs(gradientX.x) + abs(gradientY.x)) {
|
||||||
|
gradientX.x = shiftedX;
|
||||||
|
gradientY.x = shiftedY;
|
||||||
|
}
|
||||||
|
vec4 sampledDiffuseColor = textureGrad(map, vec2(uWhole, sphereV), gradientX, gradientY);
|
||||||
|
diffuseColor *= sampledDiffuseColor;
|
||||||
|
vec3 nightSample = textureGrad(nightMap, vec2(uWhole, sphereV), gradientX, gradientY).rgb * nightReady;
|
||||||
|
float glow = smoothstep(0.03, 0.12, dot(nightSample, vec3(0.2126, 0.7152, 0.0722)));
|
||||||
|
vec3 cityLights = nightSample * glow * vec3(2.6, 2.1, 1.4);
|
||||||
|
float sunHeight = dot(spherePoint, sunDirection);
|
||||||
|
float daylight = smoothstep(-0.12, 0.10, sunHeight);
|
||||||
|
vec3 dayColor = diffuseColor.rgb * sunIntensity * (0.10 + 0.90 * pow(max(sunHeight, 0.0), 0.7));
|
||||||
|
vec3 nightColor = cityLights + nightSample * 0.35 + diffuseColor.rgb * 0.015;
|
||||||
|
float terminator = exp(-pow(sunHeight / 0.05, 2.0));
|
||||||
|
earthColor = mix(nightColor, dayColor, daylight) + vec3(0.50, 0.22, 0.06) * terminator * 0.05;
|
||||||
|
#endif`)
|
||||||
|
}
|
||||||
|
material.customProgramCacheKey = () => "privapub-sunlit-sphere"
|
||||||
|
material.needsUpdate = true
|
||||||
|
state.anisotropy = Math.min(8, globe.renderer().capabilities.getMaxAnisotropy())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both maps wrap around horizontally, so filtering at the 180° meridian blends the map's two edges instead of clamping
|
||||||
|
// each, and get anisotropic filtering.
|
||||||
|
const RepeatWrapping = 1000//three.js's RepeatWrapping: the bundle does not expose THREE
|
||||||
|
function keepSharp() {
|
||||||
|
const map = state?.globe?.globeMaterial().map
|
||||||
|
if (!map)
|
||||||
|
return
|
||||||
|
if (map.anisotropy !== state.anisotropy || map.wrapS !== RepeatWrapping) {
|
||||||
|
map.anisotropy = state.anisotropy
|
||||||
|
map.wrapS = RepeatWrapping
|
||||||
|
map.needsUpdate = true
|
||||||
|
wake(500)
|
||||||
|
}
|
||||||
|
// the night map is a texture of the same kind as the day map globe.gl loaded (the bundle does not expose THREE)
|
||||||
|
if (state.nightImage?.complete && !state.sunUniforms.nightMap.value) {
|
||||||
|
const night = new map.constructor(state.nightImage)
|
||||||
|
night.colorSpace = map.colorSpace
|
||||||
|
night.anisotropy = state.anisotropy
|
||||||
|
night.wrapS = RepeatWrapping
|
||||||
|
night.needsUpdate = true
|
||||||
|
state.sunUniforms.nightMap.value = night
|
||||||
|
state.sunUniforms.nightReady.value = 1
|
||||||
|
wake(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNight() {
|
||||||
|
const image = new Image()
|
||||||
|
image.decoding = "async"
|
||||||
|
image.onload = () => {
|
||||||
|
state.nightImage = image
|
||||||
|
}
|
||||||
|
image.src = textures.night
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Sun moves to a new moment: its subsolar point travels there over about a second (the shorter way round), or
|
||||||
|
// jumps with reduced motion.
|
||||||
|
function aimSun(sun) {
|
||||||
|
state.sun = sun
|
||||||
|
state.sunUniforms.sunIntensity.value = sun.irradiance
|
||||||
|
if (reducedMotion.matches)
|
||||||
|
state.sunPoint = { lat: sun.lat, lng: sun.lng }
|
||||||
|
state.sunUniforms.sunDirection.value = directionOf(state.sunPoint)
|
||||||
|
// one frame for the new irradiance; stepSun keeps the globe drawing while the Sun travels
|
||||||
|
wake(100)
|
||||||
|
state.reference?.invokeMethodAsync("SunChanged").catch(() => { })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called every frame: eases the subsolar point towards the Sun's.
|
||||||
|
function stepSun() {
|
||||||
|
const point = state.sunPoint
|
||||||
|
const dLat = state.sun.lat - point.lat
|
||||||
|
const dLng = ((state.sun.lng - point.lng + 540) % 360) - 180
|
||||||
|
if (Math.abs(dLat) < 0.02 && Math.abs(dLng) < 0.02) {
|
||||||
|
if (point.lat !== state.sun.lat || point.lng !== state.sun.lng) {
|
||||||
|
state.sunPoint = { lat: state.sun.lat, lng: state.sun.lng }
|
||||||
|
state.sunUniforms.sunDirection.value = directionOf(state.sunPoint)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const k = Math.abs(dLat) < 0.5 && Math.abs(dLng) < 0.5 ? 1 : 0.12
|
||||||
|
state.sunPoint = { lat: point.lat + dLat * k, lng: point.lng + dLng * k }
|
||||||
|
state.sunUniforms.sunDirection.value = directionOf(state.sunPoint)
|
||||||
|
wake(300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The moment a post was written (its card's data-post-at), and its place when it has one (its own, or its server's
|
||||||
|
// before a CDN), while it is hovered, focused or pinned; otherwise now.
|
||||||
|
function focusOn(key) {
|
||||||
|
const card = key ? state.scroller?.querySelector(`[data-post-id="${CSS.escape(key)}"]`) : null
|
||||||
|
const at = card?.dataset.postAt ? new Date(card.dataset.postAt) : null
|
||||||
|
if (!at || Number.isNaN(at.getTime())) {
|
||||||
|
if (state.focus) {
|
||||||
|
state.focus = null
|
||||||
|
aimSun(sunAt(new Date()))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.focus?.key === key)
|
||||||
|
return
|
||||||
|
const post = state.points.get(key)
|
||||||
|
const lat = placed(post) ? post.lat : post?.beforeLat
|
||||||
|
const lng = placed(post) ? post.lng : post?.beforeLng
|
||||||
|
state.focus = { key, at, lat, lng, label: post?.label ?? null }
|
||||||
|
aimSun(sunAt(at))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Country names in the reader's language (Intl.DisplayNames), Natural Earth's English names when the browser has none.
|
||||||
|
function regionNames(language) {
|
||||||
|
try {
|
||||||
|
return new Intl.DisplayNames([language || navigator.language, "en"], { type: "region" })
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCountries() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(countriesUrl)
|
||||||
|
const data = await response.json()
|
||||||
|
if (!state?.globe)
|
||||||
|
return
|
||||||
|
state.borders = data.borders.map(line => {
|
||||||
|
const points = []
|
||||||
|
for (let i = 0; i < line.length; i += 2)
|
||||||
|
points.push([line[i + 1], line[i]])
|
||||||
|
return { points }
|
||||||
|
})
|
||||||
|
state.countries = data.countries.map(c => ({ kind: "country", ...c }))
|
||||||
|
state.globe.pathsData(state.borders)
|
||||||
|
drawPoints()
|
||||||
|
placeLabels(true)
|
||||||
|
wake(500)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
window.logFromJs?.(e.message, "globe.loadCountries")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryLabel(country) {
|
||||||
|
const element = document.createElement("div")
|
||||||
|
element.className = "globe-country"
|
||||||
|
element.style.visibility = "hidden"
|
||||||
|
let name = country.name
|
||||||
|
try {
|
||||||
|
name = (country.code && state.names?.of(country.code)) || country.name
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
element.textContent = name
|
||||||
|
state.labelOf.set(country, element)
|
||||||
|
state.labelKey = null
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
// How many pixels a degree of the surface spans under the camera: the surface there is altitude globe radii away, and a
|
||||||
|
// degree is a 180/π-th of a radius.
|
||||||
|
function pixelsPerDegree(altitude) {
|
||||||
|
const { height } = state.element.getBoundingClientRect()
|
||||||
|
const fov = (state.globe.camera().fov ?? 50) * Math.PI / 180
|
||||||
|
return height / (2 * Math.tan(fov / 2) * Math.max(0.01, altitude)) * Math.PI / 180
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which country names show, and how large, whenever the camera moved (at most every 100 ms): largest countries first,
|
||||||
|
// each where its polygon spans at least 34 px on screen (less towards the globe's edge, which foreshortens it), away
|
||||||
|
// from the edge, and clear of the names already placed. Names grow from 9 to 16 px with their country and fade in over
|
||||||
|
// their first pixels, so they do not pop.
|
||||||
|
function placeLabels(force = false) {
|
||||||
|
if (!state?.globe || state.labelOf.size === 0)
|
||||||
|
return
|
||||||
|
const camera = state.globe.pointOfView()
|
||||||
|
const { width, height } = state.element.getBoundingClientRect()
|
||||||
|
const key = `${camera.lat.toFixed(2)},${camera.lng.toFixed(2)},${camera.altitude.toFixed(3)},${width}x${height}`
|
||||||
|
const now = performance.now()
|
||||||
|
if (!force && (key === state.labelKey || now - state.labelsAt < 100))
|
||||||
|
return
|
||||||
|
state.labelKey = key
|
||||||
|
state.labelsAt = now
|
||||||
|
const perDegree = pixelsPerDegree(camera.altitude)
|
||||||
|
const horizon = 1 / (1 + camera.altitude)
|
||||||
|
const view = unit(camera.lat, camera.lng)
|
||||||
|
const taken = []
|
||||||
|
for (const country of state.countries) {
|
||||||
|
const element = state.labelOf.get(country)
|
||||||
|
if (!element)
|
||||||
|
continue
|
||||||
|
const point = unit(country.lat, country.lng)
|
||||||
|
const facing = (view[0] * point[0] + view[1] * point[1] + view[2] * point[2] - horizon) / (1 - horizon)
|
||||||
|
const pixels = country.size * perDegree * Math.sqrt(Math.max(facing, 0))
|
||||||
|
let shown = facing > 0.2 && pixels >= 34
|
||||||
|
if (shown) {
|
||||||
|
const size = Math.max(9, Math.min(16, 8 + pixels / 20))
|
||||||
|
const at = state.globe.getScreenCoords(country.lat, country.lng, 0.004)
|
||||||
|
const halfWidth = element.textContent.length * size * 0.3 + 3
|
||||||
|
const halfHeight = size * 0.6 + 2
|
||||||
|
const box = [at.x - halfWidth, at.y - halfHeight, at.x + halfWidth, at.y + halfHeight]
|
||||||
|
shown = !taken.some(b => box[0] < b[2] && box[2] > b[0] && box[1] < b[3] && box[3] > b[1])
|
||||||
|
if (shown) {
|
||||||
|
taken.push(box)
|
||||||
|
element.style.fontSize = `${size.toFixed(1)}px`
|
||||||
|
element.style.opacity = Math.min(1, 0.45 + (pixels - 34) / 50).toFixed(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.style.visibility = shown ? "" : "hidden"
|
||||||
|
}
|
||||||
|
state.countryNames = taken.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function unit(lat, lng) {
|
||||||
|
const φ = lat * Math.PI / 180, λ = lng * Math.PI / 180
|
||||||
|
return [Math.cos(φ) * Math.cos(λ), Math.cos(φ) * Math.sin(λ), Math.sin(φ)]
|
||||||
|
}
|
||||||
|
|
||||||
// Which cards are in view: an IntersectionObserver on every [data-post-id] of the scroll container, re-attached as
|
// Which cards are in view: an IntersectionObserver on every [data-post-id] of the scroll container, re-attached as
|
||||||
// the list changes; hover by delegation on the container.
|
// the list changes; hover by delegation on the container.
|
||||||
function watchCards() {
|
function watchCards() {
|
||||||
@@ -222,6 +578,29 @@ function watchCards() {
|
|||||||
hover(card.dataset.postId)
|
hover(card.dataset.postId)
|
||||||
})
|
})
|
||||||
listen(scroller, "mouseleave", () => hover(null))
|
listen(scroller, "mouseleave", () => hover(null))
|
||||||
|
listen(scroller, "focusin", event => {
|
||||||
|
const card = event.target.closest?.("[data-post-id]")
|
||||||
|
if (card && card.dataset.postId !== state.hovered)
|
||||||
|
hover(card.dataset.postId)
|
||||||
|
})
|
||||||
|
listen(scroller, "focusout", event => {
|
||||||
|
if (!event.relatedTarget?.closest?.("[data-post-id]") && !scroller.matches(":hover"))
|
||||||
|
hover(null)
|
||||||
|
})
|
||||||
|
// a click on a card (not on its links or controls) pins it: the globe keeps its moment and place until it is
|
||||||
|
// clicked again or another card is pinned
|
||||||
|
listen(scroller, "click", event => {
|
||||||
|
const card = event.target.closest?.("[data-post-id]")
|
||||||
|
if (!card || event.target.closest("a, button, input, textarea, select, label, summary, video, audio, [role=button]"))
|
||||||
|
return
|
||||||
|
const key = card.dataset.postId
|
||||||
|
scroller.querySelectorAll("[data-globe-pinned]").forEach(c => c.removeAttribute("data-globe-pinned"))
|
||||||
|
state.pinned = state.pinned === key ? null : key
|
||||||
|
if (state.pinned)
|
||||||
|
card.setAttribute("data-globe-pinned", "")
|
||||||
|
state.hovered = null
|
||||||
|
hover(key)
|
||||||
|
})
|
||||||
listen(scroller, "mouseout", event => {
|
listen(scroller, "mouseout", event => {
|
||||||
const from = event.target.closest?.("[data-post-id]")
|
const from = event.target.closest?.("[data-post-id]")
|
||||||
const to = event.relatedTarget?.closest?.("[data-post-id]")
|
const to = event.relatedTarget?.closest?.("[data-post-id]")
|
||||||
@@ -257,24 +636,49 @@ function pause(paused) {
|
|||||||
if (!state?.globe || state.paused === paused)
|
if (!state?.globe || state.paused === paused)
|
||||||
return
|
return
|
||||||
state.paused = paused
|
state.paused = paused
|
||||||
if (paused)
|
render()
|
||||||
state.globe.pauseAnimation()
|
}
|
||||||
else
|
|
||||||
|
// globe.gl draws every frame; this keeps its loop running only while the globe is shown and something moves.
|
||||||
|
function render() {
|
||||||
|
const run = !state.paused && !state.idle
|
||||||
|
if (run === state.running)
|
||||||
|
return
|
||||||
|
state.running = run
|
||||||
|
if (run)
|
||||||
state.globe.resumeAnimation()
|
state.globe.resumeAnimation()
|
||||||
|
else
|
||||||
|
state.globe.pauseAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Something is about to move: draw for at least ms more.
|
||||||
|
function wake(ms = 1500) {
|
||||||
|
if (!state?.globe)
|
||||||
|
return
|
||||||
|
state.awakeUntil = Math.max(state.awakeUntil, performance.now() + ms)
|
||||||
|
if (state.idle) {
|
||||||
|
state.idle = false
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked every second: nothing hovered, dragged, rotating or flying, so the last frame can stay on screen.
|
||||||
|
function settle() {
|
||||||
|
if (!state?.globe || state.idle)
|
||||||
|
return
|
||||||
|
if (state.hovered || state.interacting || state.globe.controls().autoRotate || performance.now() < state.awakeUntil)
|
||||||
|
return
|
||||||
|
state.idle = true
|
||||||
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme() {
|
function applyTheme() {
|
||||||
if (!state?.globe)
|
if (!state?.globe)
|
||||||
return
|
return
|
||||||
const dark = window.neoTheme?.isDark?.() ?? matchMedia("(prefers-color-scheme: dark)").matches
|
|
||||||
const texture = dark ? "night" : "day"
|
|
||||||
if (state.texture !== texture) {
|
|
||||||
state.texture = texture
|
|
||||||
state.globe.globeImageUrl(textures[texture])
|
|
||||||
}
|
|
||||||
state.colours = null
|
state.colours = null
|
||||||
state.globe.atmosphereColor(colour("server", 1))
|
state.globe.atmosphereColor(colour("server", 1))
|
||||||
drawPoints()
|
drawPoints()
|
||||||
|
wake(2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The theme's colours, resolved to rgb (the tokens are hsl() of CSS variables, which three.js cannot read).
|
// The theme's colours, resolved to rgb (the tokens are hsl() of CSS variables, which three.js cannot read).
|
||||||
@@ -351,7 +755,7 @@ function drawPoints() {
|
|||||||
return
|
return
|
||||||
state.groups = cdnGroups()
|
state.groups = cdnGroups()
|
||||||
state.globe.pointsData(aggregate())
|
state.globe.pointsData(aggregate())
|
||||||
state.globe.htmlElementsData(state.groups)
|
state.globe.htmlElementsData([...state.groups.map(g => ({ kind: "cdn", ...g })), ...state.countries])
|
||||||
drawRings()
|
drawRings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,10 +793,13 @@ function drawRings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hover(key) {
|
function hover(key) {
|
||||||
|
key ??= state?.pinned
|
||||||
if (!state?.globe || state.hovered === key)
|
if (!state?.globe || state.hovered === key)
|
||||||
return
|
return
|
||||||
state.hovered = key
|
state.hovered = key
|
||||||
|
wake(1300)
|
||||||
const post = key && state.points.get(key)
|
const post = key && state.points.get(key)
|
||||||
|
focusOn(key)
|
||||||
drawRings()
|
drawRings()
|
||||||
if (!post) {
|
if (!post) {
|
||||||
state.globe.arcsData([])
|
state.globe.arcsData([])
|
||||||
@@ -434,6 +841,7 @@ function updateView() {
|
|||||||
state.view = null
|
state.view = null
|
||||||
state.spreadKm = 0
|
state.spreadKm = 0
|
||||||
controls.autoRotate = !reducedMotion.matches
|
controls.autoRotate = !reducedMotion.matches
|
||||||
|
wake()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
controls.autoRotate = false
|
controls.autoRotate = false
|
||||||
@@ -458,6 +866,7 @@ function updateView() {
|
|||||||
function fly(view, ms) {
|
function fly(view, ms) {
|
||||||
if (!view || !state?.globe)
|
if (!view || !state?.globe)
|
||||||
return
|
return
|
||||||
|
wake((reducedMotion.matches ? 0 : ms) + 400)
|
||||||
state.globe.pointOfView(view, reducedMotion.matches ? 0 : ms)
|
state.globe.pointOfView(view, reducedMotion.matches ? 0 : ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,14 +900,18 @@ function countFrames() {
|
|||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (state !== current)
|
if (state !== current)
|
||||||
return
|
return
|
||||||
if (!state.paused)
|
if (state.running)
|
||||||
state.frames++
|
state.frames++
|
||||||
|
keepSharp()
|
||||||
|
stepSun()
|
||||||
|
placeLabels()
|
||||||
requestAnimationFrame(tick)
|
requestAnimationFrame(tick)
|
||||||
}
|
}
|
||||||
requestAnimationFrame(tick)
|
requestAnimationFrame(tick)
|
||||||
state.timers.push(setInterval(() => {
|
state.timers.push(setInterval(() => {
|
||||||
state.fps = state.paused ? 0 : state.frames
|
state.fps = state.running ? state.frames : 0
|
||||||
state.frames = 0
|
state.frames = 0
|
||||||
|
settle()
|
||||||
}, 1000))
|
}, 1000))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Where the Sun is, for the globe's day and night (wwwroot/js/globe.js): the Astronomical Almanac's low-precision solar
|
||||||
|
// coordinates, good to about 0.01° between 1950 and 2050. Everything here is a pure function of a date.
|
||||||
|
|
||||||
|
const radians = Math.PI / 180
|
||||||
|
const J2000 = Date.UTC(2000, 0, 1, 12)//2000-01-01 12:00 UTC
|
||||||
|
|
||||||
|
// The subsolar point (where the Sun is overhead), the Earth–Sun distance and the angles that place the Sun in the year.
|
||||||
|
export function sunAt(date) {
|
||||||
|
const days = (date.getTime() - J2000) / 86400000
|
||||||
|
const meanLongitude = 280.460 + 0.9856474 * days
|
||||||
|
const meanAnomaly = (357.528 + 0.9856003 * days) * radians
|
||||||
|
const eclipticLongitude = degrees(meanLongitude + 1.915 * Math.sin(meanAnomaly) + 0.020 * Math.sin(2 * meanAnomaly))
|
||||||
|
const obliquity = 23.439 - 0.0000004 * days
|
||||||
|
const declination = Math.asin(Math.sin(obliquity * radians) * Math.sin(eclipticLongitude * radians)) / radians
|
||||||
|
const rightAscension = Math.atan2(Math.cos(obliquity * radians) * Math.sin(eclipticLongitude * radians), Math.cos(eclipticLongitude * radians)) / radians
|
||||||
|
const siderealTime = 280.46061837 + 360.98564736629 * days
|
||||||
|
const distance = 1.00014 - 0.01671 * Math.cos(meanAnomaly) - 0.00014 * Math.cos(2 * meanAnomaly)
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
lat: declination,
|
||||||
|
lng: longitude(rightAscension - siderealTime),
|
||||||
|
distance,
|
||||||
|
irradiance: 1 / (distance * distance),
|
||||||
|
eclipticLongitude,
|
||||||
|
obliquity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the Sun does at a place: its elevation, the phase of the day, the local solar time, the day's length, the season
|
||||||
|
// in that hemisphere and the next equinox or solstice.
|
||||||
|
export function sunAtPlace(sun, lat, lng) {
|
||||||
|
const hourAngle = longitude(lng - sun.lng)
|
||||||
|
const elevation = Math.asin(
|
||||||
|
Math.sin(lat * radians) * Math.sin(sun.lat * radians) + Math.cos(lat * radians) * Math.cos(sun.lat * radians) * Math.cos(hourAngle * radians)) / radians
|
||||||
|
const solarHours = (((12 + hourAngle / 15) % 24) + 24) % 24
|
||||||
|
// sunrise and sunset as almanacs give them: the Sun's upper edge on the horizon, refraction included (−0.833°)
|
||||||
|
const sunset = (Math.sin(-0.833 * radians) - Math.sin(lat * radians) * Math.sin(sun.lat * radians)) /
|
||||||
|
(Math.cos(lat * radians) * Math.cos(sun.lat * radians))
|
||||||
|
const dayLength = sunset <= -1 ? 24 : sunset >= 1 ? 0 : 2 * Math.acos(sunset) / radians / 15
|
||||||
|
const north = lat >= 0
|
||||||
|
const seasons = seasonsOf(sun)
|
||||||
|
return {
|
||||||
|
elevation,
|
||||||
|
phase: elevation > 6 ? "day" : elevation > 0 ? "golden" : elevation > -6 ? "civil" : elevation > -12 ? "nautical" : elevation > -18 ? "astronomical" : "night",
|
||||||
|
morning: hourAngle < 0,
|
||||||
|
solarTime: `${String(Math.floor(solarHours)).padStart(2, "0")}:${String(Math.floor(solarHours % 1 * 60)).padStart(2, "0")}`,
|
||||||
|
dayLength,
|
||||||
|
hemisphere: north ? "north" : "south",
|
||||||
|
season: north ? seasons.north : seasons.south,
|
||||||
|
nextEvent: seasons.nextEvent,
|
||||||
|
nextEventDays: seasons.nextEventDays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The astronomical season in each hemisphere (from the equinoxes and solstices, by the Sun's ecliptic longitude) and the
|
||||||
|
// next equinox or solstice, in days at the Sun's mean pace.
|
||||||
|
export function seasonsOf(sun) {
|
||||||
|
const quarter = Math.floor(sun.eclipticLongitude / 90) % 4
|
||||||
|
const nextBoundary = (quarter + 1) * 90
|
||||||
|
return {
|
||||||
|
north: ["spring", "summer", "autumn", "winter"][quarter],
|
||||||
|
south: ["autumn", "winter", "spring", "summer"][quarter],
|
||||||
|
nextEvent: { 90: "june-solstice", 180: "september-equinox", 270: "december-solstice", 360: "march-equinox" }[nextBoundary],
|
||||||
|
nextEventDays: Math.round((nextBoundary - sun.eclipticLongitude) / 0.9856)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Sun's direction in the frame of three.js's SphereGeometry, the frame the globe's shader draws in: texture u is
|
||||||
|
// 0 at longitude −180°, and a point is (−cos 2πu · cos lat, sin lat, sin 2πu · cos lat).
|
||||||
|
export function directionOf(sun) {
|
||||||
|
const u = (sun.lng + 180) / 360 * 2 * Math.PI
|
||||||
|
const lat = sun.lat * radians
|
||||||
|
return [-Math.cos(u) * Math.cos(lat), Math.sin(lat), Math.sin(u) * Math.cos(lat)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function degrees(value) {
|
||||||
|
return ((value % 360) + 360) % 360
|
||||||
|
}
|
||||||
|
|
||||||
|
function longitude(value) {
|
||||||
|
const wrapped = degrees(value + 180) - 180
|
||||||
|
return wrapped === -180 ? 180 : wrapped
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
Copyright 2013-2019 Michael Bostock
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||||
|
with or without fee is hereby granted, provided that the above copyright notice
|
||||||
|
and this permission notice appear in all copies.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||||
|
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||||
|
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
||||||
|
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||||
|
THIS SOFTWARE.
|
||||||
Vendored
+16
-4
@@ -1,17 +1,29 @@
|
|||||||
# The globe's vendored files
|
# The globe's vendored files
|
||||||
|
|
||||||
Copied once from the npm registry (registry.npmjs.org) on 2026-10-04; the build and the app never fetch them from
|
Copied once on 2026-10-04 from the npm registry (registry.npmjs.org) and, for the day texture, from NASA
|
||||||
anywhere else. To update, download the new tarballs from the registry, copy the same files, and replace the hashes.
|
(eoimages.gsfc.nasa.gov); the build and the app never fetch them from anywhere else. To update, download the new files,
|
||||||
|
copy them the same way (the day texture through `tools/globe/prepare-day-texture.py`, the countries through
|
||||||
|
`tools/globe/prepare-countries.py`), and replace the hashes.
|
||||||
|
|
||||||
|
NASA's mosaic does not wrap cleanly at the 180° meridian near the poles (a lighter strip at its east edge), which the
|
||||||
|
globe, drawing each pixel at its exact place, shows as a line from each pole; the script blends that strip into the
|
||||||
|
map on both sides, only on rows where the two sides agree, and gives the rows within half a degree of each pole one
|
||||||
|
colour.
|
||||||
|
|
||||||
| File | From | Licence | sha256 |
|
| File | From | Licence | sha256 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `globe.gl.min.js` | `globe.gl` 2.46.2, `dist/globe.gl.min.js` (UMD, defines `window.Globe`) | MIT, `LICENSE-globe.gl` | `2c3e445c04d121215910a89688b96091c8a72071c122a4f830081a39b636c94c` |
|
| `globe.gl.min.js` | `globe.gl` 2.46.2, `dist/globe.gl.min.js` (UMD, defines `window.Globe`) | MIT, `LICENSE-globe.gl` | `2c3e445c04d121215910a89688b96091c8a72071c122a4f830081a39b636c94c` |
|
||||||
| `earth-blue-marble.jpg` | `three-globe` 2.45.3, `example/img/earth-blue-marble.jpg`, 4096×2048 | NASA Visible Earth "Blue Marble", public domain | `228deba2e4b600146bdcb6cfa359b8ead6aacc2b1c13550a29cd82824cfa1c01` |
|
| `earth-blue-marble.jpg` | NASA Visible Earth, Blue Marble Next Generation with topography and bathymetry, December 2004 (`world.topo.bathy.200412.3x5400x2700.jpg`, sha256 `a9f0088972dee0254610af851c4d6838ca3f2cf79176987e0a5713e2c15ec042`), seam repaired and scaled to 4096×2048 by `tools/globe/prepare-day-texture.py` | NASA, public domain | `1334612d91d5104afb0e48cce916e100575c2969baf99e21454c23535f4ce713` |
|
||||||
| `earth-night.jpg` | `three-globe` 2.45.3, `example/img/earth-night.jpg`, 4096×2048 | NASA Earth Observatory "Black Marble" (city lights), public domain | `355ab23dd1323315b393d7b91dd2d7ee223a1cbaaba2b48dc72ba90d371ced24` |
|
| `earth-night.jpg` | `three-globe` 2.45.3, `example/img/earth-night.jpg`, 4096×2048 | NASA Earth Observatory "Black Marble" (city lights), public domain | `355ab23dd1323315b393d7b91dd2d7ee223a1cbaaba2b48dc72ba90d371ced24` |
|
||||||
|
| `countries.json` | `world-atlas` 2.0.2 (tarball sha256 `032e7765f2ce00edaeafec23ff22bc3b77e42987c257da944cc585b452c05b97`), `countries-50m.json` (sha256 `04342cdc1e3016bcd7db1630de95684d67b79fe3c8c460321e87aef469502394`): land borders and name positions, by `tools/globe/prepare-countries.py`; two-letter codes from Debian's `iso-codes` 4.20.1 (`iso_3166-1.json`, LGPL-2.1, only the code mapping is used) | Natural Earth 1:50m, public domain; `world-atlas` ISC, `LICENSE-world-atlas` | `5194ad47c539b4c89826a48616ddbef2b2d2fbd2ca9a368dd6d2bced81e0d74f` |
|
||||||
|
|
||||||
`globe.gl.min.js` bundles its dependencies: three.js r185 (MIT, © 2010–2026 three.js authors), three-globe (MIT, © 2019
|
`globe.gl.min.js` bundles its dependencies: three.js r185 (MIT, © 2010–2026 three.js authors), three-globe (MIT, © 2019
|
||||||
Vasco Asturiano), @tweenjs/tween.js (MIT), kapsule and accessor-fn (MIT, © Vasco Asturiano), and d3 modules (ISC, ©
|
Vasco Asturiano), @tweenjs/tween.js (MIT), kapsule and accessor-fn (MIT, © Vasco Asturiano), and d3 modules (ISC, ©
|
||||||
Mike Bostock). Their notices are in `LICENSES-bundled.txt`.
|
Mike Bostock). Their notices are in `LICENSES-bundled.txt`.
|
||||||
|
|
||||||
|
Country names are not in `countries.json` beyond Natural Earth's English ones: the globe asks the browser
|
||||||
|
(`Intl.DisplayNames`) for each two-letter code in the reader's language, and falls back to the English name for the
|
||||||
|
few areas without a code (Kosovo, Somaliland, Northern Cyprus, …).
|
||||||
|
|
||||||
The service worker leaves this folder out of the offline precache (`service-worker.published.js`): the feed page
|
The service worker leaves this folder out of the offline precache (`service-worker.published.js`): the feed page
|
||||||
loads it the first time it shows the globe.
|
loads it the first time it shows the globe.
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"borders":[[31.29,-22.4,31.2,-22.35,31.07,-22.31,30.92,-22.29,30.71,-22.3,30.46,-22.33,30.19,-22.29,29.9,-22.18,29.66,-22.15,29.38,-22.19,29.36,-22.19],[29.36,-22.19,29.32,-22.16,29.24,-22.08,29.11,-22.07,29.07,-22.05,29.04,-22.02,29.02,-21.98,29.01,-21.94,29.04,-21.81,29.03,-21.8,28.99,-21.78,28.92,-21.77,28.75,-21.71,28.53,-21.65,28.18,-21.59,28.05,-21.57,28.01,-21.55,27.97,-21.51,27.91,-21.36,27.84,-21.26,27.69,-21.11,27.67,-21.06,27.68,-20.94,27.69,-20.85,27.7,-20.77,27.7,-20.69,27.69,-20.6,27.7,-20.53,27.68,-20.5,27.62,-20.48,27.47,-20.48,27.28,-20.48,27.28,-20.38,27.26,-20.23,27.22,-20.15,27.18,-20.1,27.09,-20.05,26.92,-19.99,26.68,-19.89,26.47,-19.75,26.24,-19.57,26.17,-19.54,26.08,-19.37,25.95,-19.08,25.96,-18.99,25.94,-18.94,25.81,-18.8,25.79,-18.72,25.76,-18.65,25.56,-18.44,25.49,-18.35,25.44,-18.23,25.39,-18.14,25.34,-18.1,25.28,-18.04,25.24,-17.97,25.22,-17.91,25.24,-17.84,25.26,-17.79],[25.26,-17.79,25.45,-17.85,25.56,-17.85,25.64,-17.82,25.74,-17.86,25.86,-17.95,26.0,-17.97,26.14,-17.91,26.33,-17.93,26.58,-18.02,26.78,-18.04,27.02,-17.96,27.24,-17.73,27.44,-17.51,27.64,-17.26,27.76,-17.06,27.93,-16.9,28.16,-16.77,28.4,-16.66,28.76,-16.53,28.83,-16.42,28.86,-16.31,28.86,-16.14,28.87,-16.04,28.91,-15.99,28.97,-15.95,29.05,-15.9,29.29,-15.78,29.49,-15.7,29.73,-15.64,29.99,-15.64,30.25,-15.64,30.4,-15.64],[30.4,-15.64,30.4,-15.8,30.41,-15.98,30.44,-15.99,30.63,-16.0,30.94,-16.01,31.24,-16.02,31.43,-16.15,31.49,-16.18,31.69,-16.21,31.94,-16.43,32.24,-16.45,32.45,-16.52,32.64,-16.59,32.74,-16.68,32.81,-16.7,32.9,-16.7,32.95,-16.71,32.94,-16.78,32.88,-16.88,32.88,-17.04,32.97,-17.25,32.98,-17.44,32.95,-17.77,32.96,-18.08,32.96,-18.2,32.98,-18.27,33.0,-18.31,32.99,-18.36,32.94,-18.49,32.9,-18.63,32.9,-18.69,32.88,-18.73,32.86,-18.76,32.72,-18.83,32.7,-18.87,32.7,-18.94,32.72,-19.0,32.77,-19.02,32.83,-19.06,32.85,-19.1,32.85,-19.15,32.83,-19.24,32.78,-19.39,32.83,-19.56,32.89,-19.67,32.97,-19.8,33.01,-19.87,33.0,-19.93,32.99,-19.98,32.87,-20.22,32.78,-20.36,32.67,-20.52,32.53,-20.61,32.49,-20.66,32.48,-20.71,32.48,-20.83,32.48,-20.95,32.36,-21.14,32.43,-21.3,32.41,-21.31,32.37,-21.33,32.19,-21.52,32.02,-21.7,31.89,-21.83,31.74,-21.98,31.57,-22.15,31.43,-22.3,31.29,-22.4],[25.26,-17.79,25.09,-17.63,25.0,-17.57,24.93,-17.54,24.73,-17.52,24.27,-17.48,24.23,-17.49,24.04,-17.52,23.8,-17.56,23.6,-17.6,23.38,-17.64],[23.38,-17.64,23.18,-17.47,22.96,-17.29,22.72,-17.07,22.55,-16.91,22.46,-16.81,22.3,-16.69,22.19,-16.63,22.15,-16.6,22.04,-16.26,21.98,-15.95,21.98,-15.72,21.98,-15.4,21.98,-15.08,21.98,-14.76,21.98,-14.44,21.98,-14.12,21.98,-13.8,21.98,-13.48,21.98,-13.16,21.98,-13.0,22.21,-13.0,22.47,-13.0,22.74,-13.0,23.04,-13.0,23.34,-13.0,23.64,-13.0,23.84,-13.0,23.9,-13.0,23.96,-12.99,23.97,-12.96,23.88,-12.8,23.89,-12.74,23.91,-12.64,23.95,-12.54,23.99,-12.42,24.0,-12.35,23.96,-12.12,23.96,-11.99,23.97,-11.85,23.99,-11.72,23.97,-11.64,23.99,-11.59,24.01,-11.52,24.03,-11.44,24.05,-11.4,24.04,-11.37,24.02,-11.32,24.01,-11.18,23.99,-11.0,23.97,-10.87],[23.97,-10.87,24.0,-10.88,24.08,-10.89,24.11,-10.96,24.14,-11.03,24.19,-11.03,24.32,-11.07,24.37,-11.13,24.4,-11.26,24.38,-11.32,24.33,-11.37,24.38,-11.42,24.47,-11.45,24.52,-11.44,24.67,-11.35,24.73,-11.34,24.81,-11.32,24.88,-11.3,25.08,-11.26,25.18,-11.24,25.25,-11.21,25.29,-11.21,25.32,-11.24,25.29,-11.33,25.28,-11.4,25.32,-11.55,25.35,-11.62,25.41,-11.67,25.46,-11.7,25.51,-11.75,25.62,-11.74,25.85,-11.82,25.93,-11.85,26.03,-11.89,26.09,-11.9,26.34,-11.93,26.43,-11.95,26.6,-11.97,26.73,-11.98,26.83,-11.97,26.89,-11.94,26.93,-11.92,26.95,-11.9,26.98,-11.83,27.03,-11.66,27.05,-11.62,27.1,-11.59,27.16,-11.58,27.2,-11.6,27.24,-11.78,27.42,-11.94,27.49,-12.08,27.53,-12.19,27.57,-12.23,27.64,-12.27,27.76,-12.28,27.86,-12.29,28.07,-12.37,28.24,-12.43,28.36,-12.48,28.41,-12.52,28.45,-12.58,28.47,-12.62,28.51,-12.74,28.55,-12.84,28.61,-12.85,28.67,-12.86,28.73,-12.93,28.77,-12.98,28.86,-13.12,28.92,-13.21,28.94,-13.31,29.01,-13.37,29.11,-13.39,29.2,-13.4,29.25,-13.37,29.38,-13.32,29.48,-13.27,29.55,-13.25,29.6,-13.26,29.63,-13.3,29.65,-13.37,29.65,-13.42,29.72,-13.45,29.77,-13.44,29.8,-13.39,29.8,-13.37,29.8,-13.17,29.8,-12.99,29.8,-12.83,29.8,-12.63,29.8,-12.45,29.8,-12.31,29.8,-12.15,29.75,-12.16,29.69,-12.2,29.56,-12.2,29.51,-12.23,29.49,-12.27,29.5,-12.32,29.5,-12.39,29.49,-12.42,29.43,-12.43,29.34,-12.4,29.19,-12.37,29.06,-12.35,28.97,-12.26,28.85,-12.12,28.77,-12.05,28.58,-11.91,28.54,-11.88,28.48,-11.81,28.43,-11.7,28.41,-11.62,28.38,-11.57,28.36,-11.48,28.4,-11.35,28.47,-11.11,28.52,-10.93,28.54,-10.8,28.64,-10.67,28.65,-10.55,28.61,-10.4,28.62,-10.31,28.62,-10.1,28.63,-9.92,28.63,-9.83,28.6,-9.68,28.54,-9.51,28.4,-9.27,28.4,-9.22,28.49,-9.17,28.61,-9.07,28.68,-9.01,28.76,-8.93,28.79,-8.89,28.87,-8.79,28.92,-8.7,28.94,-8.59,28.9,-8.48,28.97,-8.46,29.22,-8.43,29.48,-8.39,29.77,-8.34,30.05,-8.3,30.33,-8.26,30.58,-8.22,30.75,-8.19],[30.75,-8.19,30.78,-8.27,30.83,-8.39,30.89,-8.47,30.97,-8.55,31.03,-8.6,31.08,-8.61,31.35,-8.61,31.45,-8.65,31.53,-8.71,31.56,-8.81,Line truncated
|
||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
Reference in new issue
Block a user